dsh-mobile 0.5.5 → 0.6.0

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
@@ -70,14 +70,14 @@ var BoundedRateLimiter = class {
70
70
  function opaqueToken() {
71
71
  return randomBytes(32).toString("base64url");
72
72
  }
73
- function digest(value) {
73
+ function digest$1(value) {
74
74
  return createHash("sha256").update(value, "utf8").digest();
75
75
  }
76
76
  function digestHex(value) {
77
- return digest(value).toString("hex");
77
+ return digest$1(value).toString("hex");
78
78
  }
79
79
  function matchesDigest(value, expected) {
80
- return timingSafeEqual(digest(value), expected);
80
+ return timingSafeEqual(digest$1(value), expected);
81
81
  }
82
82
  function normalizeLabel(value) {
83
83
  const label = (value ?? "Mobile device").normalize("NFC").trim();
@@ -175,7 +175,7 @@ var AccessController = class {
175
175
  const record = Object.freeze({
176
176
  key,
177
177
  deviceId,
178
- csrfDigest: digest(csrfToken),
178
+ csrfDigest: digest$1(csrfToken),
179
179
  createdAt: now,
180
180
  expiresAt: Math.min(now + this.options.sessionTtlMs, deviceExpiresAt)
181
181
  });
@@ -196,7 +196,7 @@ var AccessController = class {
196
196
  const token = opaqueToken();
197
197
  const expiresAt = this.now() + ttl;
198
198
  this.pairingWindow = Object.freeze({
199
- digest: digest(token),
199
+ digest: digest$1(token),
200
200
  expiresAt
201
201
  });
202
202
  return Object.freeze({
@@ -289,7 +289,7 @@ var AccessController = class {
289
289
  }
290
290
  requireDevice(deviceToken, now) {
291
291
  if (deviceToken.length > 512) throw new AccessError(401, "authentication_failed");
292
- const tokenDigest = digest(deviceToken);
292
+ const tokenDigest = digest$1(deviceToken);
293
293
  const device = this.devices.find((candidate) => timingSafeEqual(Buffer.from(candidate.tokenDigest, "hex"), tokenDigest));
294
294
  if (device === void 0) throw new AccessError(401, "authentication_failed");
295
295
  if (device.revokedAt !== void 0) throw new AccessError(401, "device_revoked");
@@ -904,6 +904,7 @@ const Config = z.object({
904
904
  maxWebSockets: z.natural(),
905
905
  maxBodyBytes: z.natural(),
906
906
  upstreamTimeoutMs: z.natural(),
907
+ upstreamApiTimeoutMs: z.natural(),
907
908
  rateLimitWindowMs: z.natural(),
908
909
  maxPairingAttempts: z.natural(),
909
910
  maxRateLimitKeys: z.natural()
@@ -1052,6 +1053,7 @@ function parseGatewayConfig(raw) {
1052
1053
  maxWebSockets: integer(value.maxWebSockets, "maxWebSockets", 16, 1, 256),
1053
1054
  maxBodyBytes: integer(value.maxBodyBytes, "maxBodyBytes", 167772160, 1024, 268435456),
1054
1055
  upstreamTimeoutMs: integer(value.upstreamTimeoutMs, "upstreamTimeoutMs", 3e4, 1e3, 3e5),
1056
+ upstreamApiTimeoutMs: integer(value.upstreamApiTimeoutMs, "upstreamApiTimeoutMs", 0, 0, 2147483647),
1055
1057
  rateLimitWindowMs: integer(value.rateLimitWindowMs, "rateLimitWindowMs", 6e4, 1e3, 36e5),
1056
1058
  maxPairingAttempts: integer(value.maxPairingAttempts, "maxPairingAttempts", 8, 1, 100),
1057
1059
  maxRateLimitKeys: integer(value.maxRateLimitKeys, "maxRateLimitKeys", 256, 1, 4096)
@@ -1133,6 +1135,180 @@ async function restrictPrivateFile(file, mode = 384) {
1133
1135
  });
1134
1136
  }
1135
1137
  //#endregion
1138
+ //#region src/client-module-preferences.ts
1139
+ const MAX_RECORD_BYTES = 1048576;
1140
+ /** Validate untrusted JSON selections; graph membership is checked by the Gateway. */
1141
+ function parseExcludedClientModules(value) {
1142
+ if (!Array.isArray(value) || value.some((id) => typeof id !== "string" || id.length === 0 || id.trim() !== id || /[\u0000-\u001f\u007f]/u.test(id))) throw new Error("excluded_client_modules_invalid");
1143
+ const ids = value;
1144
+ if (new Set(ids).size !== ids.length) throw new Error("excluded_client_modules_invalid");
1145
+ return Object.freeze([...ids]);
1146
+ }
1147
+ function deviceKey(deviceId) {
1148
+ if (!/^[a-f\d]{32}$/u.test(deviceId)) throw new Error("client_module_device_invalid");
1149
+ }
1150
+ function parseRecord(value) {
1151
+ if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("client_module_preferences_invalid");
1152
+ const record = value;
1153
+ if (record.version !== 1 || record.devices === null || typeof record.devices !== "object" || Array.isArray(record.devices) || Object.keys(record).some((key) => ![
1154
+ "version",
1155
+ "computer",
1156
+ "devices"
1157
+ ].includes(key))) throw new Error("client_module_preferences_invalid");
1158
+ const devices = {};
1159
+ for (const [id, excluded] of Object.entries(record.devices)) {
1160
+ deviceKey(id);
1161
+ devices[id] = parseExcludedClientModules(excluded);
1162
+ }
1163
+ return Object.freeze({
1164
+ version: 1,
1165
+ devices: Object.freeze(devices),
1166
+ ...record.computer === void 0 ? {} : { computer: parseExcludedClientModules(record.computer) }
1167
+ });
1168
+ }
1169
+ /** Atomic, serialized preferences shared by the LAN and remote Gateway instances. */
1170
+ var ClientModulePreferenceStore = class {
1171
+ file;
1172
+ defaults;
1173
+ value = Object.freeze({
1174
+ version: 1,
1175
+ devices: Object.freeze({})
1176
+ });
1177
+ initialization;
1178
+ queue = Promise.resolve();
1179
+ constructor(file, defaults) {
1180
+ this.file = file;
1181
+ if (!isAbsolute(file)) throw new Error("client module preference path must be absolute");
1182
+ this.defaults = parseExcludedClientModules(defaults);
1183
+ }
1184
+ /** Read existing state once. Missing state is not written during initialization. */
1185
+ initialize() {
1186
+ this.initialization ??= this.load();
1187
+ return this.initialization;
1188
+ }
1189
+ async load() {
1190
+ let information;
1191
+ try {
1192
+ information = await lstat(this.file);
1193
+ } catch (error) {
1194
+ if (error.code === "ENOENT") return;
1195
+ throw error;
1196
+ }
1197
+ if (!information.isFile() || information.isSymbolicLink() || information.size > MAX_RECORD_BYTES) throw new Error("client module preferences must be a regular file no larger than 1 MiB");
1198
+ this.value = parseRecord(JSON.parse(await readFile(this.file, "utf8")));
1199
+ }
1200
+ selection(deviceId) {
1201
+ if (deviceId !== void 0) deviceKey(deviceId);
1202
+ const computer = this.value.computer ?? this.defaults;
1203
+ const device = deviceId === void 0 ? void 0 : this.value.devices[deviceId];
1204
+ return Object.freeze({
1205
+ excludedClientModules: device ?? computer,
1206
+ defaultExcludedClientModules: computer,
1207
+ source: device !== void 0 ? "device" : this.value.computer !== void 0 ? "computer" : "plugin"
1208
+ });
1209
+ }
1210
+ exclusive(action) {
1211
+ const operation = this.queue.then(action);
1212
+ this.queue = operation.then(() => void 0, () => void 0);
1213
+ return operation;
1214
+ }
1215
+ /** Resolve one device override, otherwise the computer or plugin default. */
1216
+ async read(deviceId) {
1217
+ await this.initialize();
1218
+ return this.exclusive(async () => this.selection(deviceId));
1219
+ }
1220
+ /** Revalidate against the current boot manifest inside the serialized mutation. */
1221
+ async configure(excluded, deviceId, validate) {
1222
+ const ids = parseExcludedClientModules(excluded);
1223
+ if (deviceId !== void 0) deviceKey(deviceId);
1224
+ await this.initialize();
1225
+ return this.exclusive(async () => {
1226
+ await validate(ids);
1227
+ const next = deviceId === void 0 ? {
1228
+ ...this.value,
1229
+ computer: ids
1230
+ } : {
1231
+ ...this.value,
1232
+ devices: {
1233
+ ...this.value.devices,
1234
+ [deviceId]: ids
1235
+ }
1236
+ };
1237
+ await this.save(next);
1238
+ return this.selection(deviceId);
1239
+ });
1240
+ }
1241
+ /** Remove only the selected override; retain all other device selections. */
1242
+ async reset(deviceId, validate) {
1243
+ if (deviceId !== void 0) deviceKey(deviceId);
1244
+ await this.initialize();
1245
+ return this.exclusive(async () => {
1246
+ const devices = { ...this.value.devices };
1247
+ if (deviceId !== void 0) delete devices[deviceId];
1248
+ const next = {
1249
+ version: 1,
1250
+ devices,
1251
+ ...deviceId === void 0 || this.value.computer === void 0 ? {} : { computer: this.value.computer }
1252
+ };
1253
+ await validate(deviceId === void 0 ? this.defaults : this.value.computer ?? this.defaults);
1254
+ await this.save(next);
1255
+ return this.selection(deviceId);
1256
+ });
1257
+ }
1258
+ /** Remove exactly the revoked device's record without evaluating its obsolete graph. */
1259
+ removeDevice(deviceId) {
1260
+ return this.removeDevices([deviceId]);
1261
+ }
1262
+ /** Remove an explicit set of revoked identities, not every record in the shared store. */
1263
+ async removeDevices(deviceIds) {
1264
+ for (const deviceId of deviceIds) deviceKey(deviceId);
1265
+ await this.initialize();
1266
+ await this.exclusive(async () => {
1267
+ if (!deviceIds.some((id) => this.value.devices[id] !== void 0)) return;
1268
+ const devices = { ...this.value.devices };
1269
+ for (const deviceId of deviceIds) delete devices[deviceId];
1270
+ await this.save({
1271
+ ...this.value,
1272
+ devices
1273
+ });
1274
+ });
1275
+ }
1276
+ async save(record) {
1277
+ const valid = parseRecord(record);
1278
+ const body = `${JSON.stringify(valid)}\n`;
1279
+ if (Buffer.byteLength(body) > MAX_RECORD_BYTES) throw new Error("client_module_preferences_limit");
1280
+ const directory = dirname(this.file);
1281
+ if (await mkdir(directory, {
1282
+ recursive: true,
1283
+ mode: 448
1284
+ }) !== void 0) await restrictPrivateFile(directory, 448);
1285
+ try {
1286
+ const existing = await lstat(this.file);
1287
+ if (!existing.isFile() || existing.isSymbolicLink()) throw new Error("client module preference target must be a regular file");
1288
+ } catch (error) {
1289
+ if (error.code !== "ENOENT") throw error;
1290
+ }
1291
+ const temporary = join(directory, `.${basename(this.file)}.${randomBytes(12).toString("hex")}.tmp`);
1292
+ try {
1293
+ await writeFile(temporary, body, {
1294
+ encoding: "utf8",
1295
+ flag: "wx",
1296
+ mode: 384
1297
+ });
1298
+ await restrictPrivateFile(temporary);
1299
+ await rename(temporary, this.file);
1300
+ this.value = valid;
1301
+ } catch (error) {
1302
+ try {
1303
+ await rm(temporary, { force: true });
1304
+ } catch (cleanupError) {
1305
+ throw new AggregateError([error, cleanupError], "client module preference write and temporary cleanup failed");
1306
+ }
1307
+ throw error;
1308
+ }
1309
+ }
1310
+ };
1311
+ //#endregion
1136
1312
  //#region src/control.ts
1137
1313
  /** Keeps one runtime aligned with a changing network selection. */
1138
1314
  var FollowingMobileAccessRuntime = class {
@@ -1380,6 +1556,88 @@ var MobileAccessGatewayController = class {
1380
1556
  return run;
1381
1557
  }
1382
1558
  };
1559
+ //#endregion
1560
+ //#region src/client-module-recovery.ts
1561
+ const CLIENT_MODULE_RECOVERY_COPY = {
1562
+ zh: {
1563
+ title: "移动页面模块需要调整",
1564
+ explanation: "电脑上的插件或依赖关系已变化,之前保存的模块选择无法加载当前页面。",
1565
+ scope: "可为本设备加载全部模块并重新打开 DSH。不会修改电脑默认设置、其他设备或已安装的插件。",
1566
+ action: "为本设备加载全部模块并重试",
1567
+ working: "正在更新本设备的模块选择…",
1568
+ failed: "未能恢复。请确认电脑和远程通道仍在线,然后重试。"
1569
+ },
1570
+ en: {
1571
+ title: "Mobile page modules need attention",
1572
+ explanation: "The computer’s plugins or dependencies have changed. The saved module selection can no longer load this page.",
1573
+ scope: "Load all modules for this device and reopen DSH. Computer defaults, other devices, and installed plugins are unchanged.",
1574
+ action: "Load all modules for this device and retry",
1575
+ working: "Updating this device’s module selection…",
1576
+ failed: "Could not restore the page. Check that the computer and remote connection are online, then retry."
1577
+ },
1578
+ it: {
1579
+ title: "I moduli della pagina mobile richiedono attenzione",
1580
+ explanation: "I plugin o le dipendenze del computer sono cambiati. La selezione salvata non può più caricare questa pagina.",
1581
+ scope: "Carica tutti i moduli per questo dispositivo e riapri DSH. I predefiniti del computer, gli altri dispositivi e i plugin installati non cambiano.",
1582
+ action: "Carica tutti i moduli per questo dispositivo e riprova",
1583
+ working: "Aggiornamento dei moduli per questo dispositivo…",
1584
+ failed: "Ripristino non riuscito. Verifica che il computer e la connessione remota siano online, poi riprova."
1585
+ }
1586
+ };
1587
+ /** Render an explicit device-only recovery action without interpolating graph diagnostics or credentials.
1588
+ * @param locale - Negotiated browser display language.
1589
+ * @returns An owned HTML page which changes no selection until the user activates its button.
1590
+ */
1591
+ function renderClientModuleRecoveryPage(locale) {
1592
+ const copy = CLIENT_MODULE_RECOVERY_COPY[locale];
1593
+ return `<!doctype html>
1594
+ <html lang="${locale === "zh" ? "zh-CN" : locale}">
1595
+ <head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
1596
+ <meta name="color-scheme" content="light dark"><title>${copy.title}</title>
1597
+ <style>
1598
+ body{margin:0;background:Canvas;color:CanvasText;font:16px/1.5 system-ui,sans-serif}
1599
+ main{box-sizing:border-box;max-width:640px;margin:32px auto;padding:24px max(24px,env(safe-area-inset-left)) 24px max(24px,env(safe-area-inset-right));overflow-wrap:anywhere}
1600
+ h1{font-size:24px;line-height:1.3}button{min-height:48px;max-width:100%;padding:12px 20px;border:1px solid ButtonText;border-radius:12px;background:ButtonFace;color:ButtonText;font:inherit;cursor:pointer}
1601
+ button:focus-visible{outline:2px solid Highlight;outline-offset:3px}button:disabled{opacity:.6;cursor:wait}#recovery-status{min-height:1.5em}
1602
+ </style></head>
1603
+ <body><main><h1>${copy.title}</h1><p>${copy.explanation}</p><p>${copy.scope}</p>
1604
+ <button id="load-all-modules" type="button">${copy.action}</button><p id="recovery-status" role="status" aria-live="polite"></p>
1605
+ </main><script>${renderClientModuleRecoveryScript(locale)}<\/script></body></html>
1606
+ `;
1607
+ }
1608
+ /** Render the fixed same-origin action; CSRF values are read from the active browser cookie only at user activation.
1609
+ * @param locale - Negotiated browser display language.
1610
+ * @returns JavaScript that preserves selections on failure and reloads only after an authorized save succeeds.
1611
+ */
1612
+ function renderClientModuleRecoveryScript(locale) {
1613
+ const copy = CLIENT_MODULE_RECOVERY_COPY[locale];
1614
+ return `(() => {
1615
+ const button = document.getElementById('load-all-modules')
1616
+ const status = document.getElementById('recovery-status')
1617
+ button.addEventListener('click', async () => {
1618
+ if (button.disabled) return
1619
+ button.disabled = true
1620
+ status.textContent = ${JSON.stringify(copy.working)}
1621
+ try {
1622
+ const values = document.cookie.split(';').map(value => value.trim())
1623
+ const host = values.filter(value => value.startsWith('__Host-dsh_ma_csrf='))
1624
+ const candidates = host.length === 0 ? values.filter(value => value.startsWith('dsh_ma_csrf=')) : host
1625
+ if (candidates.length !== 1) throw new Error('csrf_unavailable')
1626
+ const csrf = candidates[0].slice(candidates[0].indexOf('=') + 1)
1627
+ const response = await fetch('/mobile-access/client-modules', {
1628
+ method: 'POST', credentials: 'same-origin', redirect: 'error', cache: 'no-store',
1629
+ headers: { 'content-type': 'application/json', 'x-dsh-mobile-csrf': csrf },
1630
+ body: JSON.stringify({ excludedClientModules: [] }),
1631
+ })
1632
+ if (!response.ok) throw new Error('recovery_failed')
1633
+ location.reload()
1634
+ } catch {
1635
+ status.textContent = ${JSON.stringify(copy.failed)}
1636
+ button.disabled = false
1637
+ }
1638
+ })
1639
+ })()`;
1640
+ }
1383
1641
  function unwrapBrackets(hostname) {
1384
1642
  return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
1385
1643
  }
@@ -1621,10 +1879,8 @@ function commentRanges(html) {
1621
1879
  /**
1622
1880
  * Load the compatibility bundle synchronously, before the first DSH script.
1623
1881
  *
1624
- * Returns the HTML unchanged when there is no script to anchor to. The bundle is an
1625
- * enhancement for WebViews without Iterator helpers, so a document this function
1626
- * cannot place it in must still be served: throwing here took the entire dedicated
1627
- * mobile frontend down with a 502.
1882
+ * Returns the HTML unchanged when there is no executable script to anchor to.
1883
+ * The bundle supplies missing browser APIs before the page's scripts execute.
1628
1884
  */
1629
1885
  function ensureMobileCompatibility(html) {
1630
1886
  const comments = commentRanges(html);
@@ -2233,6 +2489,12 @@ function normalizeRoutePath(value) {
2233
2489
  if (normalizedInput.split("/").some((part) => part === ".." || part === ".")) throw new MobileExtensionError("invalid_route", "extension route path is invalid");
2234
2490
  return normalizedInput === "/" ? "/" : normalizedInput.replace(/\/+$/u, "");
2235
2491
  }
2492
+ /** Validate and snapshot an optional per-operation timeout override. */
2493
+ function operationTimeout(value, code) {
2494
+ if (value === void 0) return void 0;
2495
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > 3e5) throw new MobileExtensionError(code, "operation timeoutMs is invalid");
2496
+ return value;
2497
+ }
2236
2498
  function validateDefinition(definition) {
2237
2499
  const manifest = parseExtensionManifest({
2238
2500
  schemaVersion: definition.schemaVersion,
@@ -2242,9 +2504,17 @@ function validateDefinition(definition) {
2242
2504
  ...definition.description === void 0 ? {} : { description: definition.description }
2243
2505
  });
2244
2506
  const actionNames = /* @__PURE__ */ new Set();
2507
+ const actions = {};
2245
2508
  for (const [name, action] of Object.entries(definition.actions ?? {})) {
2246
2509
  if (!/^[a-z][a-z0-9-]{0,63}$/u.test(name) || action === null || typeof action !== "object" || typeof action.run !== "function" || actionNames.has(name)) throw new MobileExtensionError("invalid_action", `invalid action ${name}`);
2247
2510
  actionNames.add(name);
2511
+ const actionTimeout = operationTimeout(action.timeoutMs, "invalid_action");
2512
+ const actionInput = action.input;
2513
+ actions[name] = Object.freeze({
2514
+ ...actionInput === void 0 ? {} : { input: actionInput },
2515
+ run: action.run.bind(action),
2516
+ ...actionTimeout === void 0 ? {} : { timeoutMs: actionTimeout }
2517
+ });
2248
2518
  }
2249
2519
  const routeNames = /* @__PURE__ */ new Set();
2250
2520
  const routes = (definition.routes ?? []).map((route) => {
@@ -2258,11 +2528,14 @@ function validateDefinition(definition) {
2258
2528
  "PATCH",
2259
2529
  "DELETE"
2260
2530
  ].includes(method)) throw new MobileExtensionError("invalid_route", "unsupported extension route method");
2261
- const normalized = {
2262
- ...route,
2531
+ const routeTimeout = operationTimeout(route.timeoutMs, "invalid_route");
2532
+ const normalized = Object.freeze({
2263
2533
  method,
2264
- path: normalizeRoutePath(route.path)
2265
- };
2534
+ path: normalizeRoutePath(route.path),
2535
+ ...route.kind === void 0 ? {} : { kind: route.kind },
2536
+ handle: route.handle.bind(route),
2537
+ ...routeTimeout === void 0 ? {} : { timeoutMs: routeTimeout }
2538
+ });
2266
2539
  const key = routeKey(normalized);
2267
2540
  if (routeNames.has(key)) throw new MobileExtensionError("duplicate_route", `duplicate route ${key}`);
2268
2541
  routeNames.add(key);
@@ -2270,7 +2543,7 @@ function validateDefinition(definition) {
2270
2543
  });
2271
2544
  return Object.freeze({
2272
2545
  ...manifest,
2273
- ...definition.actions === void 0 ? {} : { actions: Object.freeze({ ...definition.actions }) },
2546
+ ...definition.actions === void 0 ? {} : { actions: Object.freeze(actions) },
2274
2547
  ...routes.length === 0 ? {} : { routes: Object.freeze(routes) }
2275
2548
  });
2276
2549
  }
@@ -2315,6 +2588,8 @@ var MobileAccessService = class extends Service {
2315
2588
  local = /* @__PURE__ */ new Map();
2316
2589
  retired = /* @__PURE__ */ new Map();
2317
2590
  failures = /* @__PURE__ */ new Map();
2591
+ /** Extensions with caller-detached, still-unresolved operations. */
2592
+ abandoned = /* @__PURE__ */ new Map();
2318
2593
  contentListeners = /* @__PURE__ */ new Set();
2319
2594
  contentHash = createHash("sha256").update("").digest("hex");
2320
2595
  localRoot;
@@ -2434,30 +2709,65 @@ var MobileAccessService = class extends Service {
2434
2709
  name: asset.name
2435
2710
  };
2436
2711
  }
2712
+ /** Reject new dispatches to an extension whose abandoned work is unresolved. */
2713
+ requireAdmission(id) {
2714
+ if ((this.abandoned.get(id) ?? 0) > 0) throw new MobileExtensionError("extension_busy", `extension ${id} has unresolved abandoned work`, 503);
2715
+ }
2716
+ /** Account for one operation until its underlying work actually settles. */
2717
+ trackOperation(id, operation) {
2718
+ operation.settled.then(() => {
2719
+ if (operation.detached()) {
2720
+ const remaining = (this.abandoned.get(id) ?? 1) - 1;
2721
+ if (remaining <= 0) this.abandoned.delete(id);
2722
+ else this.abandoned.set(id, remaining);
2723
+ }
2724
+ }).catch(() => void 0);
2725
+ }
2437
2726
  /** Invoke one action after parsing its input and binding the request lifetime. */
2438
2727
  async invoke(id, actionName, input, context, generation) {
2439
2728
  const extension = this.extension(id, generation);
2440
2729
  if (extension === void 0) throw new MobileExtensionError("extension_not_found", "extension not found", 404);
2441
2730
  const action = ("host" in extension ? extension.host : extension).actions?.[actionName];
2442
2731
  if (action === void 0) throw new MobileExtensionError("action_not_found", "action not found", 404);
2443
- let parsed;
2732
+ if ("host" in extension) extension.controller.signal.throwIfAborted();
2733
+ context.signal.throwIfAborted();
2734
+ this.requireAdmission(id);
2735
+ const external = "host" in extension ? combineSignalLifetime(extension.controller.signal, context.signal) : void 0;
2736
+ let operation;
2444
2737
  try {
2445
- parsed = parseActionInput(action.input, input);
2446
- } catch {
2447
- throw new MobileExtensionError("invalid_action_input", "action input is invalid", 400);
2448
- }
2449
- const lifetime = "host" in extension ? combineSignalLifetime(extension.controller.signal, context.signal) : void 0;
2450
- const signal = lifetime?.signal ?? context.signal;
2451
- try {
2452
- return await action.run({
2453
- ...context,
2454
- signal
2455
- }, parsed);
2738
+ operation = startBoundedOperation((signal) => {
2739
+ let parsed;
2740
+ try {
2741
+ parsed = parseActionInput(action.input, input);
2742
+ } catch {
2743
+ throw new MobileExtensionError("invalid_action_input", "action input is invalid", 400);
2744
+ }
2745
+ return Promise.resolve(parsed).then((value) => {
2746
+ signal.throwIfAborted();
2747
+ return action.run({
2748
+ ...context,
2749
+ signal
2750
+ }, value);
2751
+ }, () => {
2752
+ throw new MobileExtensionError("invalid_action_input", "action input is invalid", 400);
2753
+ });
2754
+ }, {
2755
+ external: external?.signal ?? context.signal,
2756
+ timeoutMs: action.timeoutMs ?? 3e4,
2757
+ timeoutError: () => new MobileExtensionError("extension_action_timeout", `extension ${id} action ${actionName} timed out`, 500),
2758
+ onDetach: () => {
2759
+ this.abandoned.set(id, (this.abandoned.get(id) ?? 0) + 1);
2760
+ }
2761
+ });
2762
+ this.trackOperation(id, operation);
2763
+ return await operation.caller;
2456
2764
  } catch (error) {
2765
+ if (error instanceof OperationCancelled) throw error.reason;
2457
2766
  if (error instanceof MobileExtensionError) throw error;
2458
2767
  throw new MobileExtensionError("extension_failed", "extension action failed", 500);
2459
2768
  } finally {
2460
- lifetime?.cleanup();
2769
+ operation?.release();
2770
+ external?.cleanup();
2461
2771
  }
2462
2772
  }
2463
2773
  /** Match one route and invoke it with a generation-bound abort signal. */
@@ -2469,25 +2779,47 @@ var MobileAccessService = class extends Service {
2469
2779
  return (candidate.kind ?? "exact") === "exact" ? candidate.path === pathname : pathname === candidate.path || pathname.startsWith(`${candidate.path}/`);
2470
2780
  });
2471
2781
  if (route === void 0) throw new MobileExtensionError("route_not_found", "route not found", 404);
2472
- const lifetime = "host" in extension ? combineSignalLifetime(extension.controller.signal, request.signal) : void 0;
2473
- let releaseLifetime = true;
2782
+ if ("host" in extension) extension.controller.signal.throwIfAborted();
2783
+ request.signal.throwIfAborted();
2784
+ this.requireAdmission(id);
2785
+ const external = "host" in extension ? combineSignalLifetime(extension.controller.signal, request.signal) : void 0;
2786
+ let operation;
2787
+ let holdForStream = false;
2474
2788
  try {
2475
- const routeRequest = lifetime === void 0 ? request : {
2789
+ operation = startBoundedOperation((signal) => route.handle({
2476
2790
  ...request,
2477
- signal: lifetime.signal
2478
- };
2479
- const result = await route.handle(routeRequest);
2791
+ signal
2792
+ }), {
2793
+ external: external?.signal ?? request.signal,
2794
+ timeoutMs: route.timeoutMs ?? 3e4,
2795
+ timeoutError: () => new MobileExtensionError("extension_route_timeout", `extension ${id} route ${method} ${pathname} timed out`, 500),
2796
+ onDetach: () => {
2797
+ this.abandoned.set(id, (this.abandoned.get(id) ?? 0) + 1);
2798
+ }
2799
+ });
2800
+ this.trackOperation(id, operation);
2801
+ operation.settled.then(() => {
2802
+ disposeLateExtensionResponse(operation?.lateValue());
2803
+ }).catch(() => void 0);
2804
+ const result = await operation.caller;
2480
2805
  if (result === null || typeof result !== "object" || typeof result.body !== "string" && !(result.body instanceof Uint8Array) && !isReadable(result.body)) throw new MobileExtensionError("invalid_route_response", "extension returned an invalid response", 500);
2481
- if (lifetime !== void 0 && isReadable(result.body)) {
2482
- releaseLifetime = false;
2483
- releaseSignalLifetimeWhenStreamSettles(result.body, lifetime.cleanup);
2806
+ if (isReadable(result.body)) {
2807
+ holdForStream = true;
2808
+ releaseSignalLifetimeWhenStreamSettles(result.body, () => {
2809
+ operation?.release();
2810
+ external?.cleanup();
2811
+ });
2484
2812
  }
2485
2813
  return result;
2486
2814
  } catch (error) {
2815
+ if (error instanceof OperationCancelled) throw error.reason;
2487
2816
  if (error instanceof MobileExtensionError) throw error;
2488
2817
  throw new MobileExtensionError("extension_failed", "extension route failed", 500);
2489
2818
  } finally {
2490
- if (releaseLifetime) lifetime?.cleanup();
2819
+ if (!holdForStream) {
2820
+ operation?.release();
2821
+ external?.cleanup();
2822
+ }
2491
2823
  }
2492
2824
  }
2493
2825
  /** Start the local directory watcher; an absent directory is intentionally inert. */
@@ -2658,6 +2990,16 @@ var MobileAccessService = class extends Service {
2658
2990
  function isReadable(value) {
2659
2991
  return value !== null && typeof value === "object" && typeof value.pipe === "function";
2660
2992
  }
2993
+ /** Destroy a route response that arrived after its caller detached; must never throw or emit unhandled stream errors. */
2994
+ function disposeLateExtensionResponse(value) {
2995
+ try {
2996
+ if (value === null || typeof value !== "object") return;
2997
+ const body = value.body;
2998
+ if (!isReadable(body) || typeof body.once !== "function" || typeof body.destroy !== "function") return;
2999
+ body.once("error", () => void 0);
3000
+ body.destroy();
3001
+ } catch {}
3002
+ }
2661
3003
  function releaseSignalLifetimeWhenStreamSettles(stream, cleanup) {
2662
3004
  let stopObserving;
2663
3005
  stopObserving = finished(stream, () => {
@@ -2680,6 +3022,77 @@ async function settleBounded(pending, timeoutMs) {
2680
3022
  })]);
2681
3023
  if (timer !== void 0) clearTimeout(timer);
2682
3024
  }
3025
+ /** Sentinel distinguishing caller detachment (deadline or external cancel) from a host error. */
3026
+ var OperationCancelled = class {
3027
+ reason;
3028
+ constructor(reason) {
3029
+ this.reason = reason;
3030
+ }
3031
+ };
3032
+ function startBoundedOperation(start, options) {
3033
+ const deadline = new AbortController();
3034
+ const combined = options.external === void 0 ? {
3035
+ signal: deadline.signal,
3036
+ cleanup: () => void 0
3037
+ } : combineSignalLifetime(options.external, deadline.signal);
3038
+ let detached = false;
3039
+ let workState;
3040
+ let lateFulfilled;
3041
+ let notifySettled;
3042
+ let callerTimer;
3043
+ let onExternal;
3044
+ const settled = new Promise((resolve) => {
3045
+ notifySettled = resolve;
3046
+ });
3047
+ const detach = (reject, reason) => {
3048
+ if (detached || workState !== void 0) return;
3049
+ detached = true;
3050
+ if (callerTimer !== void 0) clearTimeout(callerTimer);
3051
+ if (onExternal !== void 0) options.external?.removeEventListener("abort", onExternal);
3052
+ options.onDetach?.();
3053
+ deadline.abort(reason);
3054
+ reject(new OperationCancelled(reason));
3055
+ };
3056
+ const caller = new Promise((resolve, reject) => {
3057
+ callerTimer = setTimeout(() => detach(reject, options.timeoutError()), options.timeoutMs);
3058
+ onExternal = () => {
3059
+ detach(reject, combined.signal.reason);
3060
+ };
3061
+ options.external?.addEventListener("abort", onExternal, { once: true });
3062
+ (async () => {
3063
+ try {
3064
+ const value = await start(combined.signal);
3065
+ workState = "fulfilled";
3066
+ if (callerTimer !== void 0) clearTimeout(callerTimer);
3067
+ if (onExternal !== void 0) options.external?.removeEventListener("abort", onExternal);
3068
+ if (detached) combined.cleanup();
3069
+ if (detached) lateFulfilled = value;
3070
+ else resolve(value);
3071
+ notifySettled?.();
3072
+ } catch (error) {
3073
+ workState = "rejected";
3074
+ if (callerTimer !== void 0) clearTimeout(callerTimer);
3075
+ if (onExternal !== void 0) options.external?.removeEventListener("abort", onExternal);
3076
+ if (detached) combined.cleanup();
3077
+ if (!detached) reject(error);
3078
+ notifySettled?.();
3079
+ }
3080
+ })();
3081
+ });
3082
+ let released = false;
3083
+ return {
3084
+ caller,
3085
+ settled,
3086
+ detached: () => detached,
3087
+ lateValue: () => detached && workState === "fulfilled" ? lateFulfilled : void 0,
3088
+ release: () => {
3089
+ if (!released) {
3090
+ released = true;
3091
+ combined.cleanup();
3092
+ }
3093
+ }
3094
+ };
3095
+ }
2683
3096
  async function abortAndDisposeLocal(entries) {
2684
3097
  const pending = [];
2685
3098
  for (const entry of entries) {
@@ -3161,32 +3574,60 @@ var ExcludedClientModulesError = class extends Error {
3161
3574
  super(`excludedClientModules: ${message}`);
3162
3575
  }
3163
3576
  };
3164
- /** Validate graph edges before removing application rows from both manifest views. */
3165
- function excludeMobileBootEntries(entries, batches, excludedIds) {
3166
- if (excludedIds.length === 0) return;
3167
- const excluded = new Set(excludedIds);
3168
- if (excluded.size !== excludedIds.length) throw new ExcludedClientModulesError("duplicate module ids");
3169
- const byId = new Map(entries.map((entry) => [entry.id, entry]));
3577
+ /** A graph-selection conflict with a bounded diagnostic safe for authenticated callers. */
3578
+ var ClientModuleConflictError = class extends HttpError {
3579
+ detail;
3580
+ constructor(detail) {
3581
+ super(409, "excluded_client_modules_invalid");
3582
+ this.detail = detail.replace(/[\u0000-\u001f\u007f]/gu, " ").replace(/(?:https?:\/\/|[a-z]:[\\/])\S+/giu, "[redacted]").slice(0, 512);
3583
+ }
3584
+ };
3585
+ function requiredClientModule(entry, phase) {
3586
+ return MOBILE_BOOT_CORE_MODULES.has(entry.id) || entry.immediately === true || phase !== "application";
3587
+ }
3588
+ function clientModuleDependencies(entry) {
3589
+ const dependencies = /* @__PURE__ */ new Set();
3590
+ for (const [field, edges] of [["inject", entry.inject], ["external", entry.external]]) {
3591
+ if (edges !== void 0 && (!Array.isArray(edges) || edges.some((id) => typeof id !== "string"))) throw new ExcludedClientModulesError(`upstream module ${entry.id} has malformed ${field} dependencies`);
3592
+ for (const dependency of edges ?? []) dependencies.add(dependency.endsWith("/client") ? dependency.slice(0, -7) : dependency);
3593
+ }
3594
+ return Object.freeze([...dependencies]);
3595
+ }
3596
+ function clientModulePhases(entries, batches) {
3170
3597
  const phases = /* @__PURE__ */ new Map();
3171
3598
  for (const batch of batches) for (const id of batch.entries) {
3172
3599
  if (phases.has(id)) throw new ExcludedClientModulesError(`upstream module ${id} belongs to multiple batches`);
3173
3600
  phases.set(id, batch.phase);
3174
3601
  }
3175
3602
  for (const entry of entries) if (!phases.has(entry.id)) throw new ExcludedClientModulesError(`upstream module ${entry.id} belongs to no batch`);
3603
+ return phases;
3604
+ }
3605
+ /** Read only installed module ids and declared edges from the same boot parser used for serving. */
3606
+ function describeMobileClientModules(html) {
3607
+ const plan = parseMobileBootPlan(html);
3608
+ const ids = new Set(plan.entries.map((entry) => entry.id));
3609
+ const phases = plan.batches === void 0 ? /* @__PURE__ */ new Map() : clientModulePhases(plan.entries, plan.batches);
3610
+ return Object.freeze(plan.entries.map((entry) => Object.freeze({
3611
+ id: entry.id,
3612
+ required: requiredClientModule(entry, phases.get(entry.id)),
3613
+ dependencies: Object.freeze(clientModuleDependencies(entry).filter((id) => ids.has(id)))
3614
+ })));
3615
+ }
3616
+ /** Validate graph edges before removing application rows from both manifest views. */
3617
+ function excludeMobileBootEntries(entries, batches, excludedIds) {
3618
+ if (excludedIds.length === 0) return;
3619
+ const excluded = new Set(excludedIds);
3620
+ if (excluded.size !== excludedIds.length) throw new ExcludedClientModulesError("duplicate module ids");
3621
+ const byId = new Map(entries.map((entry) => [entry.id, entry]));
3622
+ const phases = clientModulePhases(entries, batches);
3176
3623
  for (const id of excluded) {
3177
3624
  const entry = byId.get(id);
3178
3625
  if (entry === void 0) throw new ExcludedClientModulesError(`module ${id} is not installed in this DSH client graph`);
3179
- if (MOBILE_BOOT_CORE_MODULES.has(id) || entry.immediately === true || phases.get(id) !== "application") throw new ExcludedClientModulesError(`module ${id} is required for mobile boot or belongs to a bootstrap batch`);
3626
+ if (requiredClientModule(entry, phases.get(id))) throw new ExcludedClientModulesError(`module ${id} is required for mobile boot or belongs to a bootstrap batch`);
3180
3627
  }
3181
3628
  for (const entry of entries) {
3182
3629
  if (excluded.has(entry.id)) continue;
3183
- for (const [field, edges] of [["inject", entry.inject], ["external", entry.external]]) {
3184
- if (edges !== void 0 && (!Array.isArray(edges) || edges.some((id) => typeof id !== "string"))) throw new ExcludedClientModulesError(`upstream module ${entry.id} has malformed ${field} dependencies`);
3185
- for (const dependency of edges ?? []) {
3186
- const packageId = dependency.endsWith("/client") ? dependency.slice(0, -7) : dependency;
3187
- if (excluded.has(packageId)) throw new ExcludedClientModulesError(`module ${entry.id} still depends on excluded module ${packageId}; exclude the dependent too or keep ${packageId}`);
3188
- }
3189
- }
3630
+ for (const packageId of clientModuleDependencies(entry)) if (excluded.has(packageId)) throw new ExcludedClientModulesError(`module ${entry.id} still depends on excluded module ${packageId}; exclude the dependent too or keep ${packageId}`);
3190
3631
  }
3191
3632
  entries.splice(0, entries.length, ...entries.filter((entry) => !excluded.has(entry.id)));
3192
3633
  for (const batch of batches) batch.entries = batch.entries.filter((id) => !excluded.has(id));
@@ -3831,13 +4272,18 @@ var MobileAccessGateway = class {
3831
4272
  removeExtensionContentListener;
3832
4273
  renewLimiter;
3833
4274
  probeLimiter;
3834
- constructor(config, store, extensions, upstreamAuthenticatedUrl, extraWebSocketPaths, blockedUpgradeLog, onDiscoveryDegraded) {
4275
+ clientModulePreferences;
4276
+ clientModuleOperations = /* @__PURE__ */ new Set();
4277
+ clientModuleTasks = /* @__PURE__ */ new Set();
4278
+ clientModuleRevocations = /* @__PURE__ */ new Set();
4279
+ constructor(config, store, extensions, upstreamAuthenticatedUrl, extraWebSocketPaths, blockedUpgradeLog, onDiscoveryDegraded, clientModulePreferences) {
3835
4280
  this.config = config;
3836
4281
  this.extensions = extensions;
3837
4282
  this.upstreamAuthenticatedUrl = upstreamAuthenticatedUrl;
3838
4283
  this.extraWebSocketPaths = extraWebSocketPaths;
3839
4284
  this.blockedUpgradeLog = blockedUpgradeLog;
3840
4285
  this.onDiscoveryDegraded = onDiscoveryDegraded;
4286
+ this.clientModulePreferences = clientModulePreferences ?? new ClientModulePreferenceStore(join(dirname(config.stateFile), `${basename(config.stateFile)}.client-modules.json`), config.excludedClientModules);
3841
4287
  this.listenerTlsEnabled = config.tls.mode === "provided";
3842
4288
  this.tlsEnabled = config.publicTls;
3843
4289
  this.access = new AccessController(store, {
@@ -3854,6 +4300,11 @@ var MobileAccessGateway = class {
3854
4300
  this.probeLimiter = new BoundedRateLimiter(Math.min(1e3, config.maxDevices * 4), config.rateLimitWindowMs, config.maxRateLimitKeys);
3855
4301
  this.removeSessionListener = this.access.onSessionEnded((authorization, reason) => {
3856
4302
  if (reason === "revoked") {
4303
+ const cleanup = this.clientModulePreferences.removeDevice(authorization.deviceId).catch(() => {
4304
+ process.emitWarning("Unable to remove revoked device client preferences", { code: "DSH_MOBILE_PREFERENCE_CLEANUP_FAILED" });
4305
+ });
4306
+ this.clientModuleRevocations.add(cleanup);
4307
+ cleanup.finally(() => this.clientModuleRevocations.delete(cleanup));
3857
4308
  if (!this.pendingDeviceRevocations.has(authorization.deviceId)) {
3858
4309
  this.pendingDeviceRevocations.add(authorization.deviceId);
3859
4310
  this.broadcastDeviceRevoked(authorization.deviceId);
@@ -3874,6 +4325,7 @@ var MobileAccessGateway = class {
3874
4325
  this.started = true;
3875
4326
  await this.access.initialize();
3876
4327
  try {
4328
+ await this.clientModulePreferences.initialize();
3877
4329
  if (this.config.pairingCaFile !== void 0) {
3878
4330
  const certificate = new X509Certificate(await readFile(this.config.pairingCaFile));
3879
4331
  const fingerprint = certificate.fingerprint256.replaceAll(":", "").toLowerCase();
@@ -4377,6 +4829,7 @@ var MobileAccessGateway = class {
4377
4829
  }
4378
4830
  const computerImages = request.method === "GET" && target.decodedPathname === `/mobile-access/computer-images`;
4379
4831
  const computerImage = request.method === "GET" && target.decodedPathname === `/mobile-access/computer-image`;
4832
+ const clientModules = target.decodedPathname === `${AUTH_PREFIX}/client-modules`;
4380
4833
  const requestedExtension = extensionTarget(target.decodedPathname);
4381
4834
  const requestedMobileBootBatch = mobileBootBatchKey(target.decodedPathname);
4382
4835
  const customAsset = request.method === "GET" ? target.decodedPathname === `/mobile-access/custom.css` ? {
@@ -4396,7 +4849,7 @@ var MobileAccessGateway = class {
4396
4849
  contentType: "text/javascript; charset=utf-8",
4397
4850
  fallback: void 0
4398
4851
  } : void 0 : void 0;
4399
- if (customAsset === void 0 && requestedMobileBootBatch === void 0 && !computerImages && !computerImage && extensionTarget(target.decodedPathname) === void 0 && (target.decodedPathname === "/mobile-access" || target.decodedPathname.startsWith(`/mobile-access/`))) throw new HttpError(404, "not_found");
4852
+ if (customAsset === void 0 && requestedMobileBootBatch === void 0 && !computerImages && !computerImage && !clientModules && extensionTarget(target.decodedPathname) === void 0 && (target.decodedPathname === "/mobile-access" || target.decodedPathname.startsWith(`/mobile-access/`))) throw new HttpError(404, "not_found");
4400
4853
  if (![
4401
4854
  "GET",
4402
4855
  "HEAD",
@@ -4425,6 +4878,44 @@ var MobileAccessGateway = class {
4425
4878
  throw error;
4426
4879
  }
4427
4880
  if (isMutation) this.requireCsrf(request, authorization);
4881
+ if (clientModules) {
4882
+ if (target.search !== "") throw new HttpError(400, "bad_request");
4883
+ if (request.method !== "GET" && request.method !== "POST") throw new HttpError(405, "method_not_allowed");
4884
+ const operation = this.allocateRequest(authorization, response, {});
4885
+ const onClose = () => {
4886
+ operation.abort();
4887
+ operation.release();
4888
+ };
4889
+ response.once("close", onClose);
4890
+ try {
4891
+ let kind = "describe";
4892
+ let excluded;
4893
+ if (request.method === "POST") {
4894
+ const body = await readJsonObject(request, this.config.maxBodyBytes);
4895
+ const keys = Object.keys(body);
4896
+ if (keys.length !== 1) throw new HttpError(400, "bad_request");
4897
+ if (keys[0] === "reset" && body.reset === true) kind = "reset";
4898
+ else if (keys[0] === "excludedClientModules") {
4899
+ kind = "configure";
4900
+ excluded = body.excludedClientModules;
4901
+ } else throw new HttpError(400, "bad_request");
4902
+ }
4903
+ const view = await this.clientModulePreferenceOperation(kind, excluded, authorization.deviceId, operation.signal);
4904
+ operation.signal.throwIfAborted();
4905
+ sendJson(response, 200, view, this.tlsEnabled);
4906
+ } catch (error) {
4907
+ if (operation.signal.aborted) return;
4908
+ if (error instanceof ClientModuleConflictError) sendJson(response, 409, {
4909
+ error: error.code,
4910
+ detail: error.detail
4911
+ }, this.tlsEnabled);
4912
+ else throw error;
4913
+ } finally {
4914
+ response.removeListener("close", onClose);
4915
+ operation.release();
4916
+ }
4917
+ return;
4918
+ }
4428
4919
  const extension = requestedExtension;
4429
4920
  if (extension !== void 0) {
4430
4921
  await this.handleExtensionRequest(extension, target, request, response, authorization);
@@ -4595,6 +5086,7 @@ var MobileAccessGateway = class {
4595
5086
  const operation = this.allocateRequest(authorization, response, {});
4596
5087
  const abort = new AbortController();
4597
5088
  response.once("close", () => {
5089
+ operation.release();
4598
5090
  abort.abort();
4599
5091
  });
4600
5092
  const generationSignal = extensions.signal(targetInfo.id, generation);
@@ -4640,6 +5132,7 @@ var MobileAccessGateway = class {
4640
5132
  const operation = this.allocateRequest(authorization, response, {});
4641
5133
  const abort = new AbortController();
4642
5134
  response.once("close", () => {
5135
+ operation.release();
4643
5136
  abort.abort();
4644
5137
  });
4645
5138
  const generationSignal = extensions.signal(targetInfo.id, generation);
@@ -4743,7 +5236,7 @@ var MobileAccessGateway = class {
4743
5236
  });
4744
5237
  this.upstreamAuthRequest = upstreamRequest;
4745
5238
  upstreamRequest.setTimeout(this.config.upstreamTimeoutMs, () => {
4746
- upstreamRequest.destroy(/* @__PURE__ */ new Error("upstream timeout"));
5239
+ upstreamRequest.destroy(new HttpError(504, "upstream_timeout"));
4747
5240
  });
4748
5241
  upstreamRequest.once("response", resolve);
4749
5242
  upstreamRequest.once("error", reject);
@@ -4771,33 +5264,125 @@ var MobileAccessGateway = class {
4771
5264
  this.upstreamAuthRequest = void 0;
4772
5265
  }
4773
5266
  }
4774
- async proxyMobileIndex(request$2, response, authorization) {
4775
- const holder = {};
4776
- const operation = this.allocateRequest(authorization, response, holder);
5267
+ /** List installed modules and resolve the next-open selection.
5268
+ * @param deviceId - Authenticated device identity; omitted for computer defaults.
5269
+ * @param callerSignal - Cancels catalog fetching when the admin request closes.
5270
+ * @returns The installed catalog and effective module selection.
5271
+ */
5272
+ describeClientModules(deviceId, callerSignal) {
5273
+ return this.clientModulePreferenceOperation("describe", void 0, deviceId, callerSignal);
5274
+ }
5275
+ /** Persist a validated device override or computer default.
5276
+ * @param excluded - Untrusted module IDs to validate against the current boot graph.
5277
+ * @param deviceId - Authenticated device identity; omitted for computer defaults.
5278
+ * @param callerSignal - Cancels fetching and validation, not an atomic write already started.
5279
+ * @returns The catalog and saved selection without reloading clients.
5280
+ */
5281
+ configureClientModules(excluded, deviceId, callerSignal) {
5282
+ return this.clientModulePreferenceOperation("configure", excluded, deviceId, callerSignal);
5283
+ }
5284
+ /** Reset one override without reloading connected clients.
5285
+ * @param deviceId - Authenticated device identity; omitted to reset computer defaults.
5286
+ * @param callerSignal - Cancels fetching and validation, not an atomic write already started.
5287
+ * @returns The catalog and inherited selection.
5288
+ */
5289
+ resetClientModules(deviceId, callerSignal) {
5290
+ return this.clientModulePreferenceOperation("reset", void 0, deviceId, callerSignal);
5291
+ }
5292
+ async clientModulePreferenceOperation(kind, excluded, deviceId, callerSignal) {
5293
+ if (this.closing || !this.started) throw new HttpError(503, "unavailable");
5294
+ const controller = new AbortController();
5295
+ const abort = () => controller.abort();
5296
+ if (callerSignal?.aborted === true) controller.abort();
5297
+ callerSignal?.addEventListener("abort", abort, { once: true });
5298
+ this.clientModuleOperations.add(controller);
5299
+ const task = (async () => {
5300
+ let html = "";
5301
+ const validate = async (selection) => {
5302
+ html = (await this.fetchAuthenticatedUpstreamIndex(controller.signal)).html;
5303
+ try {
5304
+ parseMobileBootPlan(html, selection);
5305
+ } catch (error) {
5306
+ if (error instanceof ExcludedClientModulesError) throw new ClientModuleConflictError(error.message);
5307
+ throw new HttpError(502, "upstream_unavailable");
5308
+ }
5309
+ controller.signal.throwIfAborted();
5310
+ };
5311
+ let selection;
5312
+ if (kind === "configure") {
5313
+ let ids;
5314
+ try {
5315
+ ids = parseExcludedClientModules(excluded);
5316
+ } catch {
5317
+ throw new HttpError(400, "excluded_client_modules_invalid");
5318
+ }
5319
+ selection = await this.clientModulePreferences.configure(ids, deviceId, validate);
5320
+ } else if (kind === "reset") selection = await this.clientModulePreferences.reset(deviceId, validate);
5321
+ else {
5322
+ html = (await this.fetchAuthenticatedUpstreamIndex(controller.signal)).html;
5323
+ selection = await this.clientModulePreferences.read(deviceId);
5324
+ }
5325
+ controller.signal.throwIfAborted();
5326
+ try {
5327
+ const plan = parseMobileBootPlan(html);
5328
+ return Object.freeze({
5329
+ entries: describeMobileClientModules(html),
5330
+ ...selection,
5331
+ ...plan.batches === void 0 ? {
5332
+ excludedClientModules: Object.freeze([]),
5333
+ defaultExcludedClientModules: Object.freeze([])
5334
+ } : {},
5335
+ reloadRequired: true
5336
+ });
5337
+ } catch (error) {
5338
+ if (error instanceof HttpError) throw error;
5339
+ throw new HttpError(502, "upstream_unavailable");
5340
+ }
5341
+ })();
5342
+ this.clientModuleTasks.add(task);
4777
5343
  try {
4778
- const upstreamHeaders = sanitizeRequestHeaders(request$2, this.config.upstreamOrigin);
4779
- const upstreamCookie = await this.upstreamCookieHeader();
4780
- if (upstreamCookie !== void 0) upstreamHeaders.cookie = upstreamCookie;
4781
- upstreamHeaders["accept-encoding"] = "identity";
5344
+ return await task;
5345
+ } finally {
5346
+ callerSignal?.removeEventListener("abort", abort);
5347
+ this.clientModuleOperations.delete(controller);
5348
+ this.clientModuleTasks.delete(task);
5349
+ }
5350
+ }
5351
+ async fetchAuthenticatedUpstreamIndex(signal, incoming) {
5352
+ signal.throwIfAborted();
5353
+ let upstreamRequest;
5354
+ const abort = () => {
5355
+ upstreamRequest?.destroy(requestAbortedError());
5356
+ };
5357
+ signal.addEventListener("abort", abort, { once: true });
5358
+ try {
5359
+ const headers = incoming === void 0 ? {
5360
+ host: this.config.upstreamOrigin.host,
5361
+ accept: "text/html",
5362
+ "accept-encoding": "identity"
5363
+ } : sanitizeRequestHeaders(incoming, this.config.upstreamOrigin);
5364
+ const upstreamCookie = await waitForRequestTask(this.upstreamCookieHeader(), signal);
5365
+ signal.throwIfAborted();
5366
+ if (upstreamCookie !== void 0) headers.cookie = upstreamCookie;
5367
+ headers["accept-encoding"] = "identity";
4782
5368
  const proxied = await new Promise((resolve, reject) => {
4783
- const upstreamRequest = request({
5369
+ upstreamRequest = request({
4784
5370
  protocol: "http:",
4785
5371
  hostname: stripIpv6Brackets(this.config.upstreamOrigin.hostname),
4786
5372
  port: Number(this.config.upstreamOrigin.port),
4787
5373
  method: "GET",
4788
5374
  path: "/",
4789
- headers: upstreamHeaders,
5375
+ headers,
4790
5376
  agent: false
4791
5377
  });
4792
- holder.request = upstreamRequest;
4793
5378
  upstreamRequest.setTimeout(this.config.upstreamTimeoutMs, () => {
4794
- upstreamRequest.destroy(/* @__PURE__ */ new Error("upstream timeout"));
5379
+ upstreamRequest?.destroy(new HttpError(504, "upstream_timeout"));
4795
5380
  });
4796
5381
  upstreamRequest.once("response", resolve);
4797
5382
  upstreamRequest.once("error", reject);
4798
5383
  upstreamRequest.end();
4799
5384
  });
4800
- if ((proxied.statusCode ?? 502) !== 200) throw new HttpError(502, "upstream_unavailable");
5385
+ if (proxied.statusCode !== 200) throw new HttpError(502, "upstream_unavailable");
4801
5386
  const chunks = [];
4802
5387
  let bytes = 0;
4803
5388
  for await (const chunk of proxied) {
@@ -4806,15 +5391,40 @@ var MobileAccessGateway = class {
4806
5391
  if (bytes > 4194304) throw new HttpError(502, "upstream_unavailable");
4807
5392
  chunks.push(buffer);
4808
5393
  }
5394
+ signal.throwIfAborted();
5395
+ return {
5396
+ html: Buffer.concat(chunks).toString("utf8"),
5397
+ headers: proxied.headers
5398
+ };
5399
+ } catch (error) {
5400
+ if (signal.aborted || error instanceof HttpError) throw error;
5401
+ throw new HttpError(502, "upstream_unavailable");
5402
+ } finally {
5403
+ signal.removeEventListener("abort", abort);
5404
+ upstreamRequest?.destroy();
5405
+ }
5406
+ }
5407
+ async proxyMobileIndex(request, response, authorization) {
5408
+ const holder = {};
5409
+ const operation = this.allocateRequest(authorization, response, holder);
5410
+ const onClose = () => {
5411
+ operation.abort();
5412
+ operation.release();
5413
+ };
5414
+ response.once("close", onClose);
5415
+ try {
5416
+ const index = await this.fetchAuthenticatedUpstreamIndex(operation.signal, request);
5417
+ const preference = await this.clientModulePreferences.read(authorization.deviceId);
5418
+ const excludedClientModules = preference.excludedClientModules;
4809
5419
  let body;
4810
5420
  try {
4811
- const html = Buffer.concat(chunks).toString("utf8");
4812
- const plan = parseMobileBootPlan(html, this.config.excludedClientModules);
4813
- const options = plan.rewrittenBatchEntries === void 0 ? { excludedClientModules: this.config.excludedClientModules } : {
4814
- ...await this.resolveMobileBootSizes(plan.rewrittenBatchEntries.flatMap((batch) => batch.entries)),
4815
- excludedClientModules: this.config.excludedClientModules
5421
+ const html = index.html;
5422
+ const plan = parseMobileBootPlan(html, excludedClientModules);
5423
+ const options = plan.rewrittenBatchEntries === void 0 ? { excludedClientModules } : {
5424
+ ...await this.resolveMobileBootSizes(plan.rewrittenBatchEntries.flatMap((batch) => batch.entries), operation.signal),
5425
+ excludedClientModules
4816
5426
  };
4817
- const cookies = browserAuthCookies(request$2.headers.cookie);
5427
+ const cookies = browserAuthCookies(request.headers.cookie);
4818
5428
  const csrfCookie = this.tlsEnabled && cookies.has("__Host-dsh_ma_session") ? HOST_CSRF_COOKIE : CSRF_COOKIE;
4819
5429
  const rewritten = rewriteMobileIndexWithBatch(html, {
4820
5430
  ...options,
@@ -4829,6 +5439,18 @@ var MobileAccessGateway = class {
4829
5439
  this.lastModuleExclusionError = detail;
4830
5440
  process.emitWarning(detail, { code: "DSH_MOBILE_MODULE_EXCLUSION_INVALID" });
4831
5441
  }
5442
+ if (preference.source === "device" || preference.source === "computer") {
5443
+ operation.signal.throwIfAborted();
5444
+ const locale = resolveAuthPageLocale(headerValue(request.headers, "accept-language"));
5445
+ const recovery = Buffer.from(renderClientModuleRecoveryPage(locale));
5446
+ setSecurityHeaders(response, this.tlsEnabled);
5447
+ response.writeHead(200, {
5448
+ "Content-Type": "text/html; charset=utf-8",
5449
+ "Content-Length": recovery.byteLength
5450
+ });
5451
+ response.end(recovery);
5452
+ return;
5453
+ }
4832
5454
  sendJson(response, 409, {
4833
5455
  error: "excluded_client_modules_invalid",
4834
5456
  detail
@@ -4837,7 +5459,8 @@ var MobileAccessGateway = class {
4837
5459
  }
4838
5460
  throw new HttpError(502, "upstream_unavailable");
4839
5461
  }
4840
- const headers = sanitizeResponseHeaders(proxied.headers, this.config.upstreamOrigin);
5462
+ operation.signal.throwIfAborted();
5463
+ const headers = sanitizeResponseHeaders(index.headers, this.config.upstreamOrigin);
4841
5464
  delete headers["content-length"];
4842
5465
  delete headers["content-encoding"];
4843
5466
  delete headers.etag;
@@ -4854,6 +5477,7 @@ var MobileAccessGateway = class {
4854
5477
  if (response.headersSent) response.destroy();
4855
5478
  else throw new HttpError(502, "upstream_unavailable");
4856
5479
  } finally {
5480
+ response.removeListener("close", onClose);
4857
5481
  operation.release();
4858
5482
  }
4859
5483
  }
@@ -4874,16 +5498,17 @@ var MobileAccessGateway = class {
4874
5498
  * could not be probed, keeps its own upstream `/plugins` row: a merged batch
4875
5499
  * cannot carry it, and an unknown bundle must not risk the whole batch.
4876
5500
  */
4877
- async resolveMobileBootSizes(planEntries) {
5501
+ async resolveMobileBootSizes(planEntries, signal) {
4878
5502
  const sizes = /* @__PURE__ */ new Map();
4879
5503
  const passThrough = /* @__PURE__ */ new Set();
4880
5504
  const candidates = planEntries.filter((entry) => entry.id !== MOBILE_LAYOUT_MODULE && upstreamPluginBundleUrl(entry.url, this.config.upstreamOrigin) !== void 0);
4881
5505
  let cursor = 0;
4882
5506
  const worker = async () => {
4883
5507
  while (cursor < candidates.length) {
5508
+ signal?.throwIfAborted();
4884
5509
  const index = cursor++;
4885
5510
  const source = candidates[index].url;
4886
- const size = await this.upstreamBundleSize(source);
5511
+ const size = await this.upstreamBundleSize(source, signal);
4887
5512
  if (size === void 0 || size >= MAX_MOBILE_BOOT_ENTRY_BYTES) passThrough.add(source);
4888
5513
  else sizes.set(source, size);
4889
5514
  }
@@ -4900,14 +5525,20 @@ var MobileAccessGateway = class {
4900
5525
  * counts bytes; it aborts instantly past the per-entry cap. Measurements are
4901
5526
  * cached for a short window.
4902
5527
  */
4903
- async upstreamBundleSize(source) {
5528
+ async upstreamBundleSize(source, signal) {
5529
+ signal?.throwIfAborted();
4904
5530
  const target = upstreamPluginBundleUrl(source, this.config.upstreamOrigin);
4905
5531
  if (target === void 0) return void 0;
4906
5532
  const cached = this.bootEntrySizeCache.get(source);
4907
5533
  if (cached !== void 0 && Date.now() - cached.at < MOBILE_BOOT_SIZE_CACHE_TTL_MS) return cached.size;
4908
- const upstreamCookie = await this.upstreamCookieHeader();
4909
5534
  let upstreamRequest;
5535
+ const abort = () => {
5536
+ upstreamRequest?.destroy(requestAbortedError());
5537
+ };
5538
+ signal?.addEventListener("abort", abort, { once: true });
4910
5539
  try {
5540
+ const upstreamCookie = signal === void 0 ? await this.upstreamCookieHeader() : await waitForRequestTask(this.upstreamCookieHeader(), signal);
5541
+ signal?.throwIfAborted();
4911
5542
  const proxied = await new Promise((resolve, reject) => {
4912
5543
  upstreamRequest = request({
4913
5544
  protocol: "http:",
@@ -4953,9 +5584,11 @@ var MobileAccessGateway = class {
4953
5584
  at: Date.now()
4954
5585
  });
4955
5586
  return size;
4956
- } catch {
5587
+ } catch (error) {
5588
+ if (signal?.aborted === true) throw error;
4957
5589
  return;
4958
5590
  } finally {
5591
+ signal?.removeEventListener("abort", abort);
4959
5592
  upstreamRequest?.destroy();
4960
5593
  }
4961
5594
  }
@@ -5122,6 +5755,7 @@ var MobileAccessGateway = class {
5122
5755
  }
5123
5756
  }
5124
5757
  allocateRequest(authorization, response, upstream) {
5758
+ if (this.closing || response.destroyed || response.writableEnded) throw requestAbortedError();
5125
5759
  if (this.activeRequests.size >= this.config.maxActiveRequests) throw new HttpError(429, "busy");
5126
5760
  const id = this.nextOperationId++;
5127
5761
  const controller = new AbortController();
@@ -5149,72 +5783,88 @@ var MobileAccessGateway = class {
5149
5783
  };
5150
5784
  }
5151
5785
  /**
5152
- * Proxy one request upstream. Pass-through client bundles (`GET /plugins`)
5153
- * receive bounded transient retries — the upstream resets a fraction of fresh
5154
- * connections — matching the resilience the merged-batch assembly already has.
5155
- * A request is only retried before any byte reached the client.
5786
+ * Proxy one request, retrying transient failures for plugin and asset GETs.
5787
+ * Only bodyless GETs can be replayed, before response headers reach the client.
5788
+ * One request slot and cancellation signal cover attempts and backoff together.
5789
+ * API requests and requests with bodies are forwarded once.
5156
5790
  */
5157
5791
  async proxyHttp(request, response, authorization) {
5158
- if (!(request.method === "GET" && request.url?.split("?", 1)[0]?.startsWith("/plugins/") === true)) {
5159
- try {
5160
- await this.proxyHttpOnce(request, response, authorization);
5161
- } catch (error) {
5162
- if (error instanceof HttpError) throw error;
5163
- if (response.headersSent) response.destroy();
5164
- else throw new HttpError(502, "upstream_unavailable");
5165
- }
5166
- return;
5167
- }
5168
- const delay = (attempt) => new Promise((resolve) => setTimeout(resolve, MOBILE_BOOT_RETRY_DELAY_MS * attempt));
5169
- for (let attempt = 1; attempt <= MOBILE_BOOT_UPSTREAM_ATTEMPTS; attempt++) try {
5170
- await this.proxyHttpOnce(request, response, authorization);
5171
- return;
5172
- } catch (error) {
5173
- if (response.headersSent) {
5174
- response.destroy();
5175
- return;
5176
- }
5177
- if (error instanceof HttpError || !isTransientUpstreamError(error)) throw error instanceof HttpError ? error : new HttpError(502, "upstream_unavailable");
5178
- if (attempt === MOBILE_BOOT_UPSTREAM_ATTEMPTS) throw new HttpError(502, "upstream_unavailable");
5179
- await delay(attempt);
5180
- }
5181
- throw new HttpError(502, "upstream_unavailable");
5182
- }
5183
- async proxyHttpOnce(request$3, response, authorization) {
5184
- const declared = request$3.headers["content-length"];
5792
+ const declared = request.headers["content-length"];
5185
5793
  if (declared !== void 0 && (!/^\d+$/u.test(declared) || Number(declared) > this.config.maxBodyBytes)) throw new HttpError(413, "payload_too_large");
5794
+ const replayable = request.method === "GET" && request.headers["transfer-encoding"] === void 0 && (declared === void 0 || Number(declared) === 0) && (request.url?.split("?", 1)[0]?.startsWith("/plugins/") === true || request.url?.split("?", 1)[0]?.startsWith("/assets/") === true);
5186
5795
  const holder = {};
5187
5796
  const operation = this.allocateRequest(authorization, response, holder);
5188
- let bodyDone;
5797
+ const onClose = () => {
5798
+ operation.release();
5799
+ operation.abort();
5800
+ };
5801
+ response.once("close", onClose);
5802
+ if (replayable) request.resume();
5189
5803
  try {
5190
- const upstreamHeaders = sanitizeRequestHeaders(request$3, this.config.upstreamOrigin);
5191
- const upstreamCookie = await this.upstreamCookieHeader();
5192
- if (upstreamCookie !== void 0) upstreamHeaders.cookie = upstreamCookie;
5804
+ const attempts = replayable ? MOBILE_BOOT_UPSTREAM_ATTEMPTS : 1;
5805
+ for (let attempt = 1; attempt <= attempts; attempt++) {
5806
+ operation.signal.throwIfAborted();
5807
+ try {
5808
+ await this.proxyHttpOnce(request, response, holder, operation.signal, replayable);
5809
+ return;
5810
+ } catch (error) {
5811
+ if (operation.signal.aborted || response.destroyed) return;
5812
+ if (response.headersSent) {
5813
+ response.destroy();
5814
+ return;
5815
+ }
5816
+ if (error instanceof HttpError || !isTransientUpstreamError(error)) throw error instanceof HttpError ? error : new HttpError(502, "upstream_unavailable");
5817
+ if (attempt === attempts) throw new HttpError(502, "upstream_unavailable");
5818
+ await waitForAbortableDelay(MOBILE_BOOT_RETRY_DELAY_MS * attempt, operation.signal);
5819
+ }
5820
+ }
5821
+ } catch (error) {
5822
+ if (!operation.signal.aborted) throw error;
5823
+ } finally {
5824
+ response.removeListener("close", onClose);
5825
+ operation.release();
5826
+ }
5827
+ }
5828
+ async proxyHttpOnce(request$2, response, holder, signal, replayable) {
5829
+ let bodyDone;
5830
+ try {
5831
+ const upstreamHeaders = sanitizeRequestHeaders(request$2, this.config.upstreamOrigin);
5832
+ if (request$2.headers["transfer-encoding"] !== void 0) upstreamHeaders["transfer-encoding"] = request$2.headers["transfer-encoding"];
5833
+ const upstreamCookie = await this.upstreamCookieHeader();
5834
+ signal.throwIfAborted();
5835
+ if (upstreamCookie !== void 0) upstreamHeaders.cookie = upstreamCookie;
5193
5836
  const proxied = await new Promise((resolve, reject) => {
5194
5837
  const upstreamRequest = request({
5195
5838
  protocol: "http:",
5196
5839
  hostname: stripIpv6Brackets(this.config.upstreamOrigin.hostname),
5197
5840
  port: Number(this.config.upstreamOrigin.port),
5198
- method: request$3.method,
5199
- path: request$3.url,
5841
+ method: request$2.method,
5842
+ path: request$2.url,
5200
5843
  headers: upstreamHeaders,
5201
5844
  agent: false
5202
5845
  });
5203
5846
  holder.request = upstreamRequest;
5204
5847
  upstreamRequest.setTimeout(this.config.upstreamTimeoutMs, () => {
5205
- upstreamRequest.destroy(/* @__PURE__ */ new Error("upstream timeout"));
5848
+ upstreamRequest.destroy(new HttpError(504, "upstream_timeout"));
5849
+ });
5850
+ const pathname = parseRequestTarget(request$2.url).decodedPathname;
5851
+ if (pathname === "/api" || pathname.startsWith("/api/")) upstreamRequest.once("finish", () => {
5852
+ upstreamRequest.setTimeout(this.config.upstreamApiTimeoutMs);
5206
5853
  });
5207
5854
  upstreamRequest.once("response", resolve);
5208
5855
  upstreamRequest.once("error", reject);
5209
- bodyDone = pipeline(request$3, new ByteLimitTransform(this.config.maxBodyBytes), upstreamRequest);
5210
- bodyDone.catch(reject);
5856
+ if (replayable) upstreamRequest.end();
5857
+ else {
5858
+ bodyDone = pipeline(request$2, new ByteLimitTransform(this.config.maxBodyBytes), upstreamRequest);
5859
+ bodyDone.catch(reject);
5860
+ }
5211
5861
  });
5212
5862
  setSecurityHeaders(response, this.tlsEnabled, "proxied");
5213
5863
  const headers = sanitizeResponseHeaders(proxied.headers, this.config.upstreamOrigin);
5214
5864
  const statusCode = proxied.statusCode ?? 502;
5215
- const cacheControl = revisionedStaticCacheControl(request$3, statusCode);
5865
+ const cacheControl = revisionedStaticCacheControl(request$2, statusCode);
5216
5866
  if (cacheControl !== void 0) headers["cache-control"] = cacheControl;
5217
- const compressed = shouldCompressResponse(request$3, proxied);
5867
+ const compressed = shouldCompressResponse(request$2, proxied);
5218
5868
  if (compressed) {
5219
5869
  delete headers["accept-ranges"];
5220
5870
  delete headers["content-length"];
@@ -5231,7 +5881,7 @@ var MobileAccessGateway = class {
5231
5881
  if (response.headersSent) response.destroy();
5232
5882
  throw error;
5233
5883
  } finally {
5234
- operation.release();
5884
+ delete holder.request;
5235
5885
  }
5236
5886
  }
5237
5887
  abortSessionResources(sessionKey) {
@@ -5580,12 +6230,19 @@ var MobileAccessGateway = class {
5580
6230
  const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
5581
6231
  if (typeof body.deviceId !== "string" || !/^[a-f\d]{32}$/u.test(body.deviceId)) throw new HttpError(400, "bad_request");
5582
6232
  if (!await this.access.revokeDevice(body.deviceId)) throw new HttpError(404, "not_found");
6233
+ await this.clientModulePreferences.removeDevice(body.deviceId).catch(() => {
6234
+ process.emitWarning("Unable to remove revoked device client preferences", { code: "DSH_MOBILE_PREFERENCE_CLEANUP_FAILED" });
6235
+ });
5583
6236
  sendJson(response, 200, { revoked: true }, false);
5584
6237
  return;
5585
6238
  }
5586
6239
  if (request.method === "POST" && target.decodedPathname === `${prefix}/devices/reset`) {
5587
6240
  if ((await readJsonObject(request, MAX_CONTROL_BODY_BYTES)).confirm !== true) throw new HttpError(400, "bad_request");
6241
+ const removed = this.access.listDevices().map((device) => device.id);
5588
6242
  await this.access.resetDevices();
6243
+ await this.clientModulePreferences.removeDevices(removed).catch(() => {
6244
+ process.emitWarning("Unable to remove revoked device client preferences", { code: "DSH_MOBILE_PREFERENCE_CLEANUP_FAILED" });
6245
+ });
5589
6246
  sendJson(response, 200, { reset: true }, false);
5590
6247
  return;
5591
6248
  }
@@ -5606,6 +6263,7 @@ var MobileAccessGateway = class {
5606
6263
  }
5607
6264
  async performClose() {
5608
6265
  this.closing = true;
6266
+ for (const controller of this.clientModuleOperations) controller.abort();
5609
6267
  if (this.extensionChangeTimer !== void 0) clearInterval(this.extensionChangeTimer);
5610
6268
  this.extensionChangeTimer = void 0;
5611
6269
  this.removeExtensionContentListener();
@@ -5638,6 +6296,8 @@ var MobileAccessGateway = class {
5638
6296
  });
5639
6297
  }
5640
6298
  await accessClose;
6299
+ await Promise.allSettled(this.clientModuleTasks);
6300
+ await Promise.allSettled(this.clientModuleRevocations);
5641
6301
  this.activeRequests.clear();
5642
6302
  this.activeWebSockets.clear();
5643
6303
  this.connectedSockets.clear();
@@ -6346,6 +7006,14 @@ function resolveFrpVhostHttpPort(settings) {
6346
7006
  function resolveFrpPublicPort(settings) {
6347
7007
  return settings.publicPort ?? 33080;
6348
7008
  }
7009
+ /** Public HTTPS endpoint the user opens, independent of the frps control address. */
7010
+ function resolveFrpEntryProbe(settings) {
7011
+ const origin = new URL(frpEntryOrigin(settings));
7012
+ return {
7013
+ host: origin.hostname,
7014
+ port: origin.port === "" ? 443 : Number(origin.port)
7015
+ };
7016
+ }
6349
7017
  /** HTTPS origin users actually open; the self-signed TCP entry is not on 443. */
6350
7018
  function frpEntryOrigin(settings) {
6351
7019
  if (!isFrpSelfSignedIngress(settings)) return settings.publicOrigin;
@@ -7128,7 +7796,8 @@ function parseManagedSetup(value) {
7128
7796
  "networkInterface",
7129
7797
  "listenPort",
7130
7798
  "upstreamOrigin",
7131
- "tls"
7799
+ "tls",
7800
+ "extraAllowedCidrs"
7132
7801
  ].includes(key))) throw new Error("mobile setup file has an unsupported format");
7133
7802
  if (!Number.isSafeInteger(record.listenPort) || record.listenPort < 1024 || record.listenPort > 65535) throw new Error("mobile setup listenPort must be from 1024 through 65535");
7134
7803
  if (typeof record.tls !== "object" || record.tls === null || Array.isArray(record.tls)) throw new Error("mobile setup tls must be an object");
@@ -7145,6 +7814,7 @@ function parseManagedSetup(value) {
7145
7814
  networkInterface: requiredString(record.networkInterface, "mobile setup networkInterface"),
7146
7815
  listenPort: record.listenPort,
7147
7816
  upstreamOrigin: requiredString(record.upstreamOrigin, "mobile setup upstreamOrigin"),
7817
+ ...record.extraAllowedCidrs === void 0 ? {} : { extraAllowedCidrs: parseAdditionalTrustedNetworks(record.extraAllowedCidrs) },
7148
7818
  tls: Object.freeze({
7149
7819
  mode: "managed",
7150
7820
  caCertFile: requiredString(tls.caCertFile, "mobile setup tls.caCertFile"),
@@ -7154,6 +7824,43 @@ function parseManagedSetup(value) {
7154
7824
  })
7155
7825
  });
7156
7826
  }
7827
+ /** Validate explicitly trusted networks without adding implicit VPN or private ranges.
7828
+ * @param value - CIDR strings submitted by the computer administrator.
7829
+ * @returns Deduplicated validated CIDRs in input order.
7830
+ */
7831
+ function parseAdditionalTrustedNetworks(value) {
7832
+ if (!Array.isArray(value)) throw new Error("extraAllowedCidrs must be an array of CIDR strings");
7833
+ const sources = value.map((entry) => {
7834
+ if (typeof entry !== "string" || entry.trim() === "") throw new Error("extraAllowedCidrs must be an array of CIDR strings");
7835
+ return entry.trim();
7836
+ });
7837
+ const entries = [];
7838
+ const seen = /* @__PURE__ */ new Set();
7839
+ for (const cidr of sources) {
7840
+ const parsed = parseCidr(cidr);
7841
+ const key = `${parsed.bits}:${parsed.network}:${parsed.prefix}`;
7842
+ if (seen.has(key)) continue;
7843
+ seen.add(key);
7844
+ entries.push(cidr);
7845
+ }
7846
+ return Object.freeze(entries);
7847
+ }
7848
+ /** Save extra trusted networks without replacing the interface or pairing identity.
7849
+ * @param setupFile - Existing managed setup file.
7850
+ * @param values - Explicitly selected additional networks.
7851
+ * @returns The saved managed setup.
7852
+ */
7853
+ async function saveManagedTrustedNetworks(setupFile, values) {
7854
+ const extraAllowedCidrs = parseAdditionalTrustedNetworks(values);
7855
+ const setup = parseManagedSetup(JSON.parse(await readFile(setupFile, "utf8")));
7856
+ const next = Object.freeze({
7857
+ ...setup,
7858
+ extraAllowedCidrs
7859
+ });
7860
+ await atomicWrite(setupFile, `${JSON.stringify(next, null, 2)}\n`);
7861
+ await restrictPrivateFile(setupFile);
7862
+ return next;
7863
+ }
7157
7864
  function privateIpv4(value) {
7158
7865
  const parts = value.split(".").map(Number);
7159
7866
  return parts.length === 4 && parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) && (parts[0] === 10 || parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31 || parts[0] === 192 && parts[1] === 168);
@@ -7294,10 +8001,18 @@ async function atomicWrite(file, contents) {
7294
8001
  recursive: true,
7295
8002
  mode: 448
7296
8003
  });
7297
- const temporary = join(directory, `.${basename(file)}.${process.pid}.tmp`);
7298
- await writeFile(temporary, contents, { mode: 384 });
7299
- await rename(temporary, file);
7300
- await restrictPrivateFile(file);
8004
+ const temporary = join(directory, `.${basename(file)}.${randomBytes(12).toString("hex")}.tmp`);
8005
+ try {
8006
+ await writeFile(temporary, contents, {
8007
+ mode: 384,
8008
+ flag: "wx"
8009
+ });
8010
+ await restrictPrivateFile(temporary);
8011
+ await rename(temporary, file);
8012
+ await restrictPrivateFile(file);
8013
+ } finally {
8014
+ await rm(temporary, { force: true });
8015
+ }
7301
8016
  }
7302
8017
  /** Create a long-lived CA or migrate the legacy self-signed server certificate as that CA. */
7303
8018
  async function ensureManagedCa(setup, legacy) {
@@ -7436,7 +8151,7 @@ async function materializeManagedSetup(setup, table) {
7436
8151
  return {
7437
8152
  publicOrigin: `https://${network.address}:${String(setup.listenPort)}`,
7438
8153
  listenHost: network.address,
7439
- allowedCidrs: [network.cidr],
8154
+ allowedCidrs: parseAdditionalTrustedNetworks([network.cidr, ...setup.extraAllowedCidrs ?? []]),
7440
8155
  instanceId: ca.fingerprint256.replaceAll(":", "").toLowerCase(),
7441
8156
  pairingCaFile: setup.tls.caCertFile,
7442
8157
  tls: {
@@ -7719,6 +8434,17 @@ var RemoteProviderCoordinator = class {
7719
8434
  mutate(operation) {
7720
8435
  return this.enqueue(() => operation(this.controller()));
7721
8436
  }
8437
+ /** Apply one operation only while its intended provider remains selected.
8438
+ * @param provider - Provider the caller explicitly chose.
8439
+ * @param operation - Configuration and activation performed in the same queue item.
8440
+ * @returns The operation result, or a rejection when another selection won the queue.
8441
+ */
8442
+ mutateSelected(provider, operation) {
8443
+ return this.enqueue(async () => {
8444
+ if (provider !== this.selectedValue) throw new Error("remote_provider_changed");
8445
+ return operation(this.controller());
8446
+ });
8447
+ }
7722
8448
  /** Disable the previous provider, persist the new selection, and retain rollback on write failure. */
7723
8449
  select(provider) {
7724
8450
  return this.enqueue(async () => {
@@ -9753,7 +10479,7 @@ var CloudflaredController = class {
9753
10479
  };
9754
10480
  //#endregion
9755
10481
  //#region src/cloudflared-tunnel.ts
9756
- const MAX_CONFIG_BYTES = 8192;
10482
+ const MAX_CONFIG_BYTES$1 = 8192;
9757
10483
  const QUICK_TUNNEL_SUFFIX = ".trycloudflare.com";
9758
10484
  /** Cloudflare's own tunnel-routing domain; it can never be a public hostname. */
9759
10485
  const TUNNEL_ROUTING_SUFFIX = ".cfargotunnel.com";
@@ -9912,7 +10638,7 @@ var CloudflaredTunnelStore = class {
9912
10638
  if (error.code === "ENOENT") return;
9913
10639
  throw error;
9914
10640
  }
9915
- if (!entry.isFile() || entry.isSymbolicLink() || entry.size > MAX_CONFIG_BYTES) {
10641
+ if (!entry.isFile() || entry.isSymbolicLink() || entry.size > MAX_CONFIG_BYTES$1) {
9916
10642
  this.errorCode = "cloudflared_tunnel_config_invalid";
9917
10643
  return;
9918
10644
  }
@@ -10550,6 +11276,7 @@ const MOBILE_CUSTOMIZATION_GUIDE_BODY = `你在为用户定制 DSH Mobile 的手
10550
11276
  - 目录:$DSH_HOME/mobile-access/extensions/<id>/,id 用小写字母数字和连字符(如 media-remote)
10551
11277
  - extension.json:{"schemaVersion":1,"id":"<id>","name":"显示名","version":"0.1.0","description":"说明"}
10552
11278
  - host.mjs:电脑端 Node.js 代码(可信本地代码,可读写文件、执行命令)。导出默认函数 (api) => { ... },用 api.action('名称', { input, run }) 注册动作、api.route({ method, path, handle }) 注册路由、api.effect(fn) 注册清理。input 可以直接使用 api.schema.object(...) 等 Schemastery schema,也可以传入带 parse(value) 的适配器;两种形式都会在动作执行前校验并规范化输入。
11279
+ - 动作与路由的执行有时间预算:默认 30 秒(输入校验也计入),可用 api.action('名称', { input, run, timeoutMs }) 或 api.route({ ..., timeoutMs }) 按操作覆盖(1–300000 的整数毫秒)。超时或调用方取消后,调用方收到 500 extension_action_timeout / extension_route_timeout(或取消原因);这只表示调用方不再等待,底层代码可能仍在运行并已产生副作用,请在 run / handle 中响应 signal 实现协作式取消。路由的预算到 handle() 返回响应为止,不影响已返回流的存活。若某扩展存在调用方已离开(超时或取消)但仍未结束的操作,后续对该扩展的新调用会收到 503 extension_busy,直到该操作真正结束。
10553
11280
  - mobile.js:手机端脚本,用 window.dshMobile.define({ apiVersion:1, id:'<id>', activate(api) { ... } }),activate 返回清理函数
10554
11281
  - mobile.css:手机端样式(可选)
10555
11282
  - assets/:手机端静态资源(可选)
@@ -10558,6 +11285,7 @@ const MOBILE_CUSTOMIZATION_GUIDE_BODY = `你在为用户定制 DSH Mobile 的手
10558
11285
 
10559
11286
  安全约束:
10560
11287
  - host.mjs 拥有电脑用户的完整权限,绝不能放入不可信代码,也不要让手机端无条件执行任意命令
11288
+ - host.mjs 的同步阻塞(如 while (true) {} 或长时间同步循环)会卡死整个电脑端网关进程,超时与取消机制都无法打断;绝不编写同步阻塞代码
10561
11289
  - 所有改动只限 $DSH_HOME/mobile-access/,不要动 DeepSeek Harness 源码
10562
11290
 
10563
11291
  完成前请自检:
@@ -12741,201 +13469,1160 @@ var CpolarComponentManager = class {
12741
13469
  }
12742
13470
  };
12743
13471
  //#endregion
12744
- //#region src/release-update.ts
12745
- const PACKAGE_NAME = "dsh-mobile";
12746
- const NPM_LATEST_URL = "https://registry.npmjs.org/dsh-mobile/latest";
12747
- const GITHUB_LATEST_URL = "https://github.com/saya-ch/dsh-mobile/releases/latest";
12748
- const GITHUB_API_LATEST_URL = "https://api.github.com/repos/saya-ch/dsh-mobile/releases/latest";
12749
- const GITHUB_RELEASES_URL = "https://github.com/saya-ch/dsh-mobile/releases";
12750
- const RELEASE_NOTES_MAX_CHARS = 4e3;
12751
- const STATUS_CACHE_MS = 6e5;
12752
- const REQUEST_TIMEOUT_MS = 8e3;
12753
- const UPDATE_TIMEOUT_MS = 12e4;
12754
- const UPDATE_TERMINATION_GRACE_MS = 1500;
12755
- const NUMERIC_VERSION_IDENTIFIER = "(?:0|[1-9]\\d*)";
12756
- const WILDCARD_VERSION_IDENTIFIER = "(?:[xX*])";
12757
- const RANGE_VERSION = `(?:${`${NUMERIC_VERSION_IDENTIFIER}\\.${NUMERIC_VERSION_IDENTIFIER}\\.${NUMERIC_VERSION_IDENTIFIER}(?:-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?`}|${`(?:${WILDCARD_VERSION_IDENTIFIER}|${NUMERIC_VERSION_IDENTIFIER}(?:\\.(?:${WILDCARD_VERSION_IDENTIFIER}|${NUMERIC_VERSION_IDENTIFIER}(?:\\.(?:${WILDCARD_VERSION_IDENTIFIER}|${NUMERIC_VERSION_IDENTIFIER}))?))?)`})`;
12758
- const COMPARATOR = new RegExp(`^(?:<=|>=|<|>|=|~|\\^)?${RANGE_VERSION}$`, "u");
12759
- const HYPHEN_RANGE = new RegExp(`^${RANGE_VERSION} +[-] +${RANGE_VERSION}$`, "u");
12760
- const DIST_TAG = /^[A-Za-z][A-Za-z0-9._-]{0,127}$/u;
12761
- function parseSemver(value) {
12762
- const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/u.exec(value);
12763
- if (match === null) return void 0;
12764
- const core = [
12765
- Number(match[1]),
12766
- Number(match[2]),
12767
- Number(match[3])
12768
- ];
12769
- if (core.some((part) => !Number.isSafeInteger(part))) return void 0;
12770
- const prerelease = match[4] === void 0 ? [] : match[4].split(".").map((part) => /^\d+$/u.test(part) ? Number(part) : part);
12771
- if (prerelease.some((part) => typeof part === "number" && !Number.isSafeInteger(part))) return void 0;
12772
- return Object.freeze({
12773
- core,
12774
- prerelease: Object.freeze(prerelease)
12775
- });
12776
- }
12777
- /** Compare two strict SemVer strings, including prerelease precedence. */
12778
- function comparePluginVersions(left, right) {
12779
- const a = parseSemver(left);
12780
- const b = parseSemver(right);
12781
- if (a === void 0 || b === void 0) return void 0;
12782
- for (let index = 0; index < a.core.length; index += 1) {
12783
- const difference = a.core[index] - b.core[index];
12784
- if (difference !== 0) return Math.sign(difference);
12785
- }
12786
- if (a.prerelease.length === 0 || b.prerelease.length === 0) return a.prerelease.length === b.prerelease.length ? 0 : a.prerelease.length === 0 ? 1 : -1;
12787
- const length = Math.max(a.prerelease.length, b.prerelease.length);
12788
- for (let index = 0; index < length; index += 1) {
12789
- const leftPart = a.prerelease[index];
12790
- const rightPart = b.prerelease[index];
12791
- if (leftPart === void 0 || rightPart === void 0) return leftPart === void 0 ? -1 : 1;
12792
- if (leftPart === rightPart) continue;
12793
- if (typeof leftPart === "number" && typeof rightPart === "number") return Math.sign(leftPart - rightPart);
12794
- if (typeof leftPart === "number") return -1;
12795
- if (typeof rightPart === "number") return 1;
12796
- return leftPart < rightPart ? -1 : 1;
12797
- }
12798
- return 0;
12799
- }
12800
- function isComparatorSet(value) {
12801
- if (HYPHEN_RANGE.test(value)) return true;
12802
- const comparators = value.replace(/(<=|>=|<|>|=|~|\^) +/gu, "$1").split(/ +/u);
12803
- return comparators.length > 0 && comparators.every((comparator) => COMPARATOR.test(comparator));
12804
- }
12805
- function isNpmVersionRange(value) {
12806
- if (!/^[0-9xX*<>=~^|.+\- ]+$/u.test(value)) return false;
12807
- const alternatives = value.split(/ *\|\| */u);
12808
- return alternatives.length > 0 && alternatives.every((alternative) => alternative !== "" && isComparatorSet(alternative));
12809
- }
12810
- /** Return whether pnpm may safely replace this profile dependency from an npm version, range, or tag. */
12811
- function isRegistryPluginSpec(value) {
12812
- if (typeof value !== "string" || value.trim() !== value || value === "" || /[\u0000-\u001f\u007f]/u.test(value)) return false;
12813
- if (/\.(?:tgz|tar(?:\.gz)?)$/iu.test(value)) return false;
12814
- return parseSemver(value) !== void 0 || isNpmVersionRange(value) || DIST_TAG.test(value);
12815
- }
12816
- /** Resolve the DSH profile named by the current launcher arguments. */
12817
- function launchedProfileName(argv) {
12818
- for (let index = 0; index < argv.length; index += 1) {
12819
- if (argv[index] === "--profile") {
12820
- const candidate = argv[index + 1];
12821
- if (candidate !== void 0 && /^[\w.-]+$/u.test(candidate)) return candidate;
13472
+ //#region src/caddy-files.ts
13473
+ /** Refuse link-shaped ancestors within the owned state root before filesystem operations. */
13474
+ async function assertCaddyParents(root, target) {
13475
+ const parent = resolve(root);
13476
+ const candidate = resolve(target);
13477
+ const offset = relative(parent, candidate);
13478
+ if (!isAbsolute(root) || offset !== "" && (offset.startsWith("..") || isAbsolute(offset))) throw new Error("caddy_path_invalid");
13479
+ const paths = [parent];
13480
+ let cursor = parent;
13481
+ for (const segment of relative(parent, dirname(candidate)).split(/[\\/]/u).filter(Boolean)) {
13482
+ cursor = join(cursor, segment);
13483
+ paths.push(cursor);
13484
+ }
13485
+ for (const path of paths) {
13486
+ let entry;
13487
+ try {
13488
+ entry = await lstat(path);
13489
+ } catch (error) {
13490
+ if (error.code === "ENOENT") continue;
13491
+ throw error;
12822
13492
  }
12823
- const match = /^--profile=([\w.-]+)$/u.exec(argv[index] ?? "");
12824
- if (match?.[1] !== void 0) return match[1];
12825
- }
12826
- return "web";
12827
- }
12828
- /** Resolve the launcher-owned Desktop profile, or the CLI profile outside Desktop. */
12829
- function releaseProfileDirectory(ctx, dshHome, argv) {
12830
- const desktopProfiles = ctx.get("desktopProfiles");
12831
- const desktopDirectory = desktopProfiles?.current?.dir;
12832
- if (typeof desktopDirectory === "string" && isAbsolute(desktopDirectory)) return desktopDirectory;
12833
- if (desktopProfiles !== void 0 || ctx.get("desktopRuntime") !== void 0) return void 0;
12834
- return join(dshHome, "profiles", launchedProfileName(argv));
12835
- }
12836
- async function profileDependencySpec(profileDirectory) {
12837
- try {
12838
- const value = JSON.parse(await readFile(join(profileDirectory, "package.json"), "utf8")).dependencies?.[PACKAGE_NAME];
12839
- return typeof value === "string" ? value : void 0;
12840
- } catch {
12841
- return;
13493
+ if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error("caddy_path_invalid");
12842
13494
  }
12843
13495
  }
12844
- async function fetchNpmVersion(fetcher) {
12845
- const response = await fetcher(NPM_LATEST_URL, {
12846
- headers: {
12847
- accept: "application/json",
12848
- "user-agent": "dsh-mobile-release-check"
12849
- },
12850
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
13496
+ /** Create a private real directory, never traversing a link owned by the component. */
13497
+ async function ensureCaddyDirectory(root, directory) {
13498
+ await assertCaddyParents(root, directory);
13499
+ await mkdir(directory, {
13500
+ recursive: true,
13501
+ mode: 448
12851
13502
  });
12852
- if (!response.ok) return void 0;
12853
- const payload = await response.json();
12854
- return typeof payload.version === "string" && parseSemver(payload.version) !== void 0 ? payload.version : void 0;
13503
+ const entry = await lstat(directory);
13504
+ if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error("caddy_path_invalid");
13505
+ await restrictPrivateFile(directory, 448);
12855
13506
  }
12856
- function githubReleaseVersion(location, responseUrl) {
12857
- let url;
13507
+ /** Atomically replace one private configuration file under a real owned directory. */
13508
+ async function writeCaddyPrivateFile(root, file, body) {
13509
+ await ensureCaddyDirectory(root, dirname(file));
12858
13510
  try {
12859
- url = new URL(location ?? responseUrl, GITHUB_LATEST_URL);
12860
- } catch {
12861
- return;
13511
+ const entry = await lstat(file);
13512
+ if (!entry.isFile() || entry.isSymbolicLink()) throw new Error("caddy_config_target_invalid");
13513
+ } catch (error) {
13514
+ if (error.code !== "ENOENT") throw error;
12862
13515
  }
12863
- if (url.origin !== "https://github.com" || url.username !== "" || url.password !== "" || url.search !== "" || url.hash !== "") return void 0;
12864
- if (!url.pathname.startsWith("/saya-ch/dsh-mobile/releases/tag/v")) return void 0;
12865
- let version;
13516
+ const temporary = join(dirname(file), "." + basename(file) + "." + randomBytes(12).toString("hex") + ".tmp");
12866
13517
  try {
12867
- version = decodeURIComponent(url.pathname.slice(34));
12868
- } catch {
12869
- return;
13518
+ await writeFile(temporary, body, {
13519
+ encoding: "utf8",
13520
+ flag: "wx",
13521
+ mode: 384
13522
+ });
13523
+ await restrictPrivateFile(temporary);
13524
+ await rename(temporary, file);
13525
+ } finally {
13526
+ await rm(temporary, { force: true });
12870
13527
  }
12871
- return parseSemver(version) === void 0 ? void 0 : version;
12872
- }
12873
- function androidReleaseDownloadUrl(version) {
12874
- if (version === void 0) return GITHUB_RELEASES_URL;
12875
- const tag = `v${version}`;
12876
- return `https://github.com/saya-ch/dsh-mobile/releases/download/${encodeURIComponent(tag)}/dsh-mobile-android-${encodeURIComponent(tag)}.apk`;
12877
- }
12878
- async function fetchAndroidVersion(fetcher) {
12879
- const response = await fetcher(GITHUB_LATEST_URL, {
12880
- method: "GET",
12881
- redirect: "manual",
12882
- headers: {
12883
- accept: "text/html",
12884
- "user-agent": "dsh-mobile-release-check"
12885
- },
12886
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
12887
- });
12888
- return githubReleaseVersion(response.headers.get("location"), response.url);
12889
- }
12890
- /** Best-effort body of the latest GitHub release, trimmed to a bounded size. */
12891
- async function fetchReleaseNotes(fetcher) {
12892
- const response = await fetcher(GITHUB_API_LATEST_URL, {
12893
- headers: {
12894
- accept: "application/vnd.github+json",
12895
- "user-agent": "dsh-mobile-release-check"
12896
- },
12897
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
12898
- });
12899
- if (!response.ok) return void 0;
12900
- const payload = await response.json();
12901
- if (typeof payload.body !== "string") return void 0;
12902
- const notes = payload.body.trim();
12903
- return notes === "" ? void 0 : notes.slice(0, RELEASE_NOTES_MAX_CHARS);
12904
13528
  }
12905
- async function readProfileInstalledVersion(profileDirectory) {
13529
+ /** Delete only owned entries; junctions and symlinks are unlinked, never descended into. */
13530
+ async function removeCaddyTree(root, target) {
13531
+ await assertCaddyParents(root, target);
13532
+ let entry;
12906
13533
  try {
12907
- const manifestPath = createRequire(join(profileDirectory, "package.json")).resolve(`${PACKAGE_NAME}/package.json`);
12908
- const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
12909
- return typeof manifest.version === "string" ? manifest.version : void 0;
12910
- } catch {
13534
+ entry = await lstat(target);
13535
+ } catch (error) {
13536
+ if (error.code === "ENOENT") return;
13537
+ throw error;
13538
+ }
13539
+ if (entry.isSymbolicLink()) {
13540
+ await unlink(target);
13541
+ return;
13542
+ }
13543
+ if (!entry.isDirectory()) {
13544
+ await unlink(target);
12911
13545
  return;
12912
13546
  }
13547
+ for (const name of await readdir(target)) await removeCaddyTree(root, join(target, name));
13548
+ await rmdir(target);
12913
13549
  }
12914
- function childCompletion(child) {
12915
- return new Promise((resolveCompletion, rejectCompletion) => {
12916
- child.once("error", rejectCompletion);
12917
- child.once("close", (code, signal) => {
12918
- resolveCompletion({
12919
- code,
12920
- signal
12921
- });
12922
- });
12923
- });
13550
+ //#endregion
13551
+ //#region src/caddy-component.ts
13552
+ const CADDY_VERSION = "2.11.6";
13553
+ const CADDY_DNS_PLUGIN_VERSION = "v0.4.3";
13554
+ const CADDY_DOWNLOAD_PAGE = "https://caddyserver.com/download";
13555
+ const CADDY_COMPONENT_RELEASES = Object.freeze({});
13556
+ Object.freeze({
13557
+ version: CADDY_VERSION,
13558
+ dnsPluginVersion: CADDY_DNS_PLUGIN_VERSION
13559
+ });
13560
+ function isCaddyDnsProvider(value) {
13561
+ return value === "tencentcloud";
12924
13562
  }
12925
- function createDeadline(timeoutMs) {
12926
- let timer;
13563
+ async function digest(file) {
13564
+ return createHash("sha256").update(await readFile(file)).digest("hex");
13565
+ }
13566
+ /** Minimal process environment for version/module checks, without ambient API credentials or proxy overrides. */
13567
+ function caddyInspectionEnvironment(environment = process.env) {
13568
+ const result = {};
13569
+ for (const name of [
13570
+ "SystemRoot",
13571
+ "WINDIR",
13572
+ "TEMP",
13573
+ "TMP",
13574
+ "LANG",
13575
+ "LC_ALL"
13576
+ ]) if (environment[name] !== void 0) result[name] = environment[name];
13577
+ return result;
13578
+ }
13579
+ async function inspect(executable) {
13580
+ const options = {
13581
+ env: caddyInspectionEnvironment(),
13582
+ timeout: 15e3,
13583
+ maxBuffer: 262144
13584
+ };
12927
13585
  return {
12928
- promise: new Promise((resolveTimeout) => {
12929
- timer = setTimeout(resolveTimeout, timeoutMs);
12930
- timer.unref();
12931
- }),
12932
- cancel: () => {
12933
- if (timer !== void 0) clearTimeout(timer);
12934
- timer = void 0;
12935
- }
13586
+ version: (await execFileText(executable, ["version"], options)).stdout.trim(),
13587
+ modules: (await execFileText(executable, ["list-modules", "--versions"], options)).stdout
12936
13588
  };
12937
13589
  }
12938
- async function taskkillProcessTree(pid) {
13590
+ /** Owns only the optional pinned executable; runtime configuration belongs to CaddyConfigStore. */
13591
+ var CaddyComponentManager = class {
13592
+ executable;
13593
+ componentRoot;
13594
+ componentStorage;
13595
+ logRoot;
13596
+ stateDirectory;
13597
+ stagingRoot;
13598
+ artifact;
13599
+ fetchArtifact;
13600
+ inspectExecutable;
13601
+ promote;
13602
+ installed = false;
13603
+ errorCode;
13604
+ queue = Promise.resolve();
13605
+ constructor(options) {
13606
+ if (!isAbsolute(options.stateDirectory)) throw new Error("caddy state directory must be absolute");
13607
+ this.stateDirectory = resolve(options.stateDirectory);
13608
+ const platform = options.platform ?? process.platform;
13609
+ const arch = options.arch ?? process.arch;
13610
+ const artifact = options.artifact ?? CADDY_COMPONENT_RELEASES[`${platform}-${arch}`];
13611
+ if (artifact !== void 0 && (artifact.platform !== platform || artifact.arch !== arch || !/^https:\/\//u.test(artifact.downloadUrl) || !/^[a-f0-9]{64}$/u.test(artifact.downloadSha256) || !/^[a-f0-9]{64}$/u.test(artifact.executableSha256) || artifact.downloadBytes < 1 || artifact.executableBytes < 1 || !/^[a-z0-9.-]+$/u.test(artifact.executableName))) throw new Error("caddy_artifact_invalid");
13612
+ this.artifact = artifact;
13613
+ this.componentRoot = join(this.stateDirectory, "components", "caddy");
13614
+ this.componentStorage = join(this.componentRoot, artifact?.version ?? "2.11.6");
13615
+ this.executable = join(this.componentStorage, artifact?.executableName ?? (platform === "win32" ? "caddy.exe" : "caddy"));
13616
+ this.logRoot = join(this.stateDirectory, "caddy", "logs");
13617
+ this.stagingRoot = join(this.stateDirectory, "staging", "caddy");
13618
+ this.fetchArtifact = options.fetchArtifact ?? ((url, signal) => downloadPinnedArtifact({
13619
+ url,
13620
+ signal,
13621
+ expectedBytes: artifact?.downloadBytes ?? 0,
13622
+ errorPrefix: "caddy"
13623
+ }));
13624
+ this.inspectExecutable = options.inspectExecutable ?? inspect;
13625
+ this.promote = options.promoteDirectory ?? rename;
13626
+ }
13627
+ /** Recheck a pinned executable before launching; no unverified local binary can run. */
13628
+ async ensureExecutable() {
13629
+ const artifact = this.artifact;
13630
+ if (artifact === void 0) throw new Error("caddy_component_unavailable");
13631
+ await assertCaddyParents(this.stateDirectory, this.executable);
13632
+ const entry = await lstat(this.executable);
13633
+ if (!entry.isFile() || entry.isSymbolicLink() || entry.size !== artifact.executableBytes || await digest(this.executable) !== artifact.executableSha256) throw new Error("caddy_component_invalid");
13634
+ await this.verifyMetadata(this.executable, artifact);
13635
+ }
13636
+ async initialize() {
13637
+ this.installed = false;
13638
+ this.errorCode = void 0;
13639
+ if (this.artifact === void 0) {
13640
+ this.errorCode = "caddy_component_unavailable";
13641
+ return;
13642
+ }
13643
+ try {
13644
+ await this.ensureExecutable();
13645
+ this.installed = true;
13646
+ } catch (error) {
13647
+ if (error.code !== "ENOENT") this.errorCode = "caddy_component_invalid";
13648
+ }
13649
+ }
13650
+ status() {
13651
+ const artifact = this.artifact;
13652
+ return Object.freeze({
13653
+ supported: artifact !== void 0,
13654
+ installed: this.installed,
13655
+ version: artifact?.version ?? "2.11.6",
13656
+ downloadBytes: artifact?.downloadBytes ?? 0,
13657
+ installedBytes: this.installed ? artifact?.executableBytes ?? 0 : 0,
13658
+ sourceUrl: artifact?.downloadUrl ?? "https://caddyserver.com/download",
13659
+ downloadPage: CADDY_DOWNLOAD_PAGE,
13660
+ storagePath: this.componentRoot,
13661
+ ...this.errorCode === void 0 ? {} : { errorCode: this.errorCode }
13662
+ });
13663
+ }
13664
+ /** Explicit installation verifies bytes and reported modules before preserving-old promotion. */
13665
+ install() {
13666
+ return this.enqueue(async () => {
13667
+ const artifact = this.artifact;
13668
+ if (artifact === void 0) throw new Error("caddy_component_unavailable");
13669
+ await ensureCaddyDirectory(this.stateDirectory, this.stagingRoot);
13670
+ await ensureCaddyDirectory(this.stateDirectory, this.componentRoot);
13671
+ const staging = await mkdtemp(join(this.stagingRoot, "install-"));
13672
+ const candidate = join(this.componentRoot, ".install-" + randomBytes(12).toString("hex"));
13673
+ const backup = join(this.componentRoot, ".previous-" + randomBytes(12).toString("hex"));
13674
+ let previous = false;
13675
+ let promoted = false;
13676
+ try {
13677
+ const controller = new AbortController();
13678
+ const timeout = setTimeout(() => {
13679
+ controller.abort();
13680
+ }, 6e5);
13681
+ timeout.unref();
13682
+ let bytes;
13683
+ try {
13684
+ bytes = await this.fetchArtifact(artifact.downloadUrl, controller.signal);
13685
+ } finally {
13686
+ clearTimeout(timeout);
13687
+ }
13688
+ if (bytes.byteLength !== artifact.downloadBytes) throw new Error("caddy_download_size_mismatch");
13689
+ if (createHash("sha256").update(bytes).digest("hex") !== artifact.downloadSha256) throw new Error("caddy_download_hash_mismatch");
13690
+ await ensureCaddyDirectory(this.stateDirectory, candidate);
13691
+ const executable = join(candidate, artifact.executableName);
13692
+ await writeFile(executable, bytes, {
13693
+ flag: "wx",
13694
+ mode: 448
13695
+ });
13696
+ await chmod(executable, 448);
13697
+ if (bytes.byteLength !== artifact.executableBytes || await digest(executable) !== artifact.executableSha256) throw new Error("caddy_executable_hash_mismatch");
13698
+ await this.verifyMetadata(executable, artifact);
13699
+ try {
13700
+ const entry = await lstat(this.componentStorage);
13701
+ if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error("caddy_path_invalid");
13702
+ await rename(this.componentStorage, backup);
13703
+ previous = true;
13704
+ } catch (error) {
13705
+ if (error.code !== "ENOENT") throw error;
13706
+ }
13707
+ try {
13708
+ await this.promote(candidate, this.componentStorage);
13709
+ promoted = true;
13710
+ } catch (error) {
13711
+ if (previous) {
13712
+ await rename(backup, this.componentStorage);
13713
+ previous = false;
13714
+ }
13715
+ throw error;
13716
+ }
13717
+ this.installed = true;
13718
+ this.errorCode = void 0;
13719
+ } finally {
13720
+ await removeCaddyTree(this.stateDirectory, staging);
13721
+ await removeCaddyTree(this.stateDirectory, candidate);
13722
+ if (previous && promoted) await removeCaddyTree(this.stateDirectory, backup);
13723
+ }
13724
+ });
13725
+ }
13726
+ /** Remove optional component files; callers stop the controller and purge runtime configuration separately. */
13727
+ purge() {
13728
+ return this.enqueue(async () => {
13729
+ await removeCaddyTree(this.stateDirectory, this.componentRoot);
13730
+ await removeCaddyTree(this.stateDirectory, this.stagingRoot);
13731
+ this.installed = false;
13732
+ this.errorCode = this.artifact === void 0 ? "caddy_component_unavailable" : void 0;
13733
+ });
13734
+ }
13735
+ async verifyMetadata(executable, artifact) {
13736
+ const info = await this.inspectExecutable(executable);
13737
+ if (!info.version.startsWith("v" + artifact.version + " ")) throw new Error("caddy_executable_version_mismatch");
13738
+ const nonstandard = info.modules.split(/\r?\n/u).filter((line) => line.startsWith("dns.providers."));
13739
+ if (nonstandard.length !== 1 || nonstandard[0] !== "dns.providers.tencentcloud " + artifact.dnsPluginVersion || !info.modules.includes("Non-standard modules: 1")) throw new Error("caddy_executable_modules_mismatch");
13740
+ }
13741
+ enqueue(operation) {
13742
+ const task = this.queue.then(operation, operation);
13743
+ this.queue = task.then(() => void 0, () => void 0);
13744
+ return task.then(() => this.status());
13745
+ }
13746
+ };
13747
+ const MAX_CONFIG_BYTES = 16384;
13748
+ /** Inject only the DNS credentials needed by the managed child. */
13749
+ function caddyCredentialEnvironment(provider, credentials) {
13750
+ if (!isCaddyDnsProvider(provider)) throw new Error("caddy_dns_provider_invalid");
13751
+ return {
13752
+ TENCENTCLOUD_SECRET_ID: credentials.secretId,
13753
+ TENCENTCLOUD_SECRET_KEY: credentials.secretKey
13754
+ };
13755
+ }
13756
+ /** Validate saved or administrative settings, including ports reserved by DSH. */
13757
+ function parseCaddySettings(value) {
13758
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("caddy_settings_invalid");
13759
+ const record = value;
13760
+ if (Reflect.ownKeys(record).some((key) => ![
13761
+ "version",
13762
+ "publicOrigin",
13763
+ "dnsProvider",
13764
+ "listenPort"
13765
+ ].includes(String(key))) || record.version !== void 0 && record.version !== 1) throw new Error("caddy_settings_invalid");
13766
+ if (!isCaddyDnsProvider(record.dnsProvider)) throw new Error("caddy_dns_provider_invalid");
13767
+ const listenPort = record.listenPort ?? 8443;
13768
+ if (!Number.isSafeInteger(listenPort) || Number(listenPort) < 1024 || Number(listenPort) > 65535) throw new Error("caddy_listen_port_invalid");
13769
+ if ([
13770
+ 3444,
13771
+ 3443,
13772
+ 3080
13773
+ ].includes(Number(listenPort))) throw new Error("caddy_listen_port_reserved");
13774
+ const publicOrigin = validateOriginPublicOrigin(record.publicOrigin);
13775
+ if (isIP(new URL(publicOrigin).hostname) !== 0) throw new Error("caddy_domain_required");
13776
+ return Object.freeze({
13777
+ version: 1,
13778
+ publicOrigin,
13779
+ dnsProvider: record.dnsProvider,
13780
+ listenPort: Number(listenPort)
13781
+ });
13782
+ }
13783
+ function parseCredentials(value) {
13784
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("caddy_credentials_invalid");
13785
+ const record = value;
13786
+ if (Reflect.ownKeys(record).some((key) => key !== "secretId" && key !== "secretKey")) throw new Error("caddy_credentials_invalid");
13787
+ const { secretId, secretKey } = record;
13788
+ if (typeof secretId !== "string" || typeof secretKey !== "string" || secretId.length === 0 || secretKey.length === 0 || secretId.length > 512 || secretKey.length > 512 || /[\s\u0000-\u001f\u007f]/u.test(secretId + secretKey)) throw new Error("caddy_credentials_invalid");
13789
+ return Object.freeze({
13790
+ secretId,
13791
+ secretKey
13792
+ });
13793
+ }
13794
+ /** Render a single TLS site, with no admin API, config autosave, or embedded DNS secrets. */
13795
+ function renderCaddyfile(settings, stateDirectory, backendPort = 3444) {
13796
+ const validated = parseCaddySettings(settings);
13797
+ if (!Number.isSafeInteger(backendPort) || backendPort < 1 || backendPort > 65535) throw new Error("caddy_backend_port_invalid");
13798
+ const root = resolve(stateDirectory, "caddy");
13799
+ const dataDirectory = join(root, "data").replaceAll("\\", "/");
13800
+ const hostname = new URL(validated.publicOrigin).hostname;
13801
+ return [
13802
+ "{",
13803
+ " admin off",
13804
+ " persist_config off",
13805
+ " auto_https disable_redirects",
13806
+ `\tstorage file_system ${JSON.stringify(dataDirectory)}`,
13807
+ " log {",
13808
+ " output discard",
13809
+ " }",
13810
+ "}",
13811
+ "",
13812
+ `https://${hostname}:${String(validated.listenPort)} {`,
13813
+ " tls {",
13814
+ " dns tencentcloud {",
13815
+ " secret_id {env.TENCENTCLOUD_SECRET_ID}",
13816
+ " secret_key {env.TENCENTCLOUD_SECRET_KEY}",
13817
+ " }",
13818
+ " }",
13819
+ `\treverse_proxy 127.0.0.1:${String(backendPort)} {`,
13820
+ " header_up Host {http.request.hostport}",
13821
+ " transport http {",
13822
+ " keepalive off",
13823
+ " dial_timeout 5s",
13824
+ " }",
13825
+ " }",
13826
+ "}",
13827
+ ""
13828
+ ].join("\n");
13829
+ }
13830
+ /** Owns one atomic configuration record and every Caddy-created private file. */
13831
+ var CaddyConfigStore = class {
13832
+ stateDirectory;
13833
+ rootDirectory;
13834
+ settingsFile;
13835
+ envFile;
13836
+ caddyfile;
13837
+ dataDirectory;
13838
+ configDirectory;
13839
+ logsDirectory;
13840
+ settingsValue;
13841
+ credentialsValue;
13842
+ errorCodeValue;
13843
+ queue = Promise.resolve();
13844
+ constructor(stateDirectory) {
13845
+ this.stateDirectory = stateDirectory;
13846
+ if (!isAbsolute(stateDirectory)) throw new Error("caddy config state directory must be absolute");
13847
+ this.rootDirectory = resolve(stateDirectory, "caddy");
13848
+ this.settingsFile = join(this.rootDirectory, "config.json");
13849
+ this.envFile = this.settingsFile;
13850
+ this.caddyfile = join(this.rootDirectory, "Caddyfile");
13851
+ this.dataDirectory = join(this.rootDirectory, "data");
13852
+ this.configDirectory = join(this.rootDirectory, "xdg-config");
13853
+ this.logsDirectory = join(this.rootDirectory, "logs");
13854
+ }
13855
+ settings() {
13856
+ return this.settingsValue;
13857
+ }
13858
+ credentials() {
13859
+ return this.credentialsValue;
13860
+ }
13861
+ errorCode() {
13862
+ return this.errorCodeValue;
13863
+ }
13864
+ status() {
13865
+ const settings = this.settingsValue;
13866
+ return Object.freeze({
13867
+ configured: settings !== void 0 && this.credentialsValue !== void 0,
13868
+ credentialsConfigured: this.credentialsValue !== void 0,
13869
+ storagePath: this.rootDirectory,
13870
+ ...settings === void 0 ? {} : {
13871
+ publicOrigin: settings.publicOrigin,
13872
+ listenPort: settings.listenPort,
13873
+ dnsProvider: settings.dnsProvider
13874
+ },
13875
+ ...this.errorCodeValue === void 0 ? {} : { errorCode: this.errorCodeValue }
13876
+ });
13877
+ }
13878
+ async initialize() {
13879
+ await this.enqueue(async () => {
13880
+ this.settingsValue = void 0;
13881
+ this.credentialsValue = void 0;
13882
+ this.errorCodeValue = void 0;
13883
+ await assertCaddyParents(this.stateDirectory, this.settingsFile);
13884
+ let entry;
13885
+ try {
13886
+ entry = await lstat(this.settingsFile);
13887
+ } catch (error) {
13888
+ if (error.code === "ENOENT") return;
13889
+ throw error;
13890
+ }
13891
+ if (!entry.isFile() || entry.isSymbolicLink() || entry.size > MAX_CONFIG_BYTES) {
13892
+ this.errorCodeValue = "caddy_config_invalid";
13893
+ return;
13894
+ }
13895
+ await restrictPrivateFile(this.settingsFile);
13896
+ try {
13897
+ const record = JSON.parse(await readFile(this.settingsFile, "utf8"));
13898
+ if (typeof record !== "object" || record === null || Array.isArray(record)) throw new Error("invalid");
13899
+ const values = record;
13900
+ if (Reflect.ownKeys(values).some((key) => key !== "settings" && key !== "credentials")) throw new Error("invalid");
13901
+ const settings = parseCaddySettings(values.settings);
13902
+ const credentials = parseCredentials(values.credentials);
13903
+ this.settingsValue = settings;
13904
+ this.credentialsValue = credentials;
13905
+ } catch {
13906
+ this.errorCodeValue = "caddy_config_invalid";
13907
+ }
13908
+ });
13909
+ }
13910
+ /** Atomically save validated settings and credentials; omitted or both-blank secrets retain saved ones. */
13911
+ configure(value, credentialInput) {
13912
+ return this.enqueue(async () => {
13913
+ const settings = parseCaddySettings(value);
13914
+ const blank = typeof credentialInput === "object" && credentialInput !== null && !Array.isArray(credentialInput) && credentialInput.secretId === "" && credentialInput.secretKey === "" && Reflect.ownKeys(credentialInput).every((key) => key === "secretId" || key === "secretKey");
13915
+ const credentials = credentialInput === void 0 || blank ? this.credentialsValue : parseCredentials(credentialInput);
13916
+ if (credentials === void 0) throw new Error("caddy_credentials_missing");
13917
+ await writeCaddyPrivateFile(this.stateDirectory, this.settingsFile, JSON.stringify({
13918
+ settings,
13919
+ credentials
13920
+ }) + "\n");
13921
+ this.settingsValue = settings;
13922
+ this.credentialsValue = credentials;
13923
+ this.errorCodeValue = void 0;
13924
+ });
13925
+ }
13926
+ /** Write derived runtime files after authoritative settings are available. */
13927
+ prepareCaddyfile(backendPort) {
13928
+ return this.enqueue(async () => {
13929
+ const settings = this.settingsValue;
13930
+ if (settings === void 0 || this.credentialsValue === void 0) throw new Error("caddy_config_missing");
13931
+ for (const directory of [
13932
+ this.dataDirectory,
13933
+ this.configDirectory,
13934
+ this.logsDirectory
13935
+ ]) await ensureCaddyDirectory(this.stateDirectory, directory);
13936
+ await writeCaddyPrivateFile(this.stateDirectory, this.caddyfile, renderCaddyfile(settings, this.stateDirectory, backendPort));
13937
+ });
13938
+ }
13939
+ /** Remove credentials, certificates, private keys, configuration, logs, and runtime cache. */
13940
+ purge() {
13941
+ return this.enqueue(async () => {
13942
+ await removeCaddyTree(this.stateDirectory, this.rootDirectory);
13943
+ this.settingsValue = void 0;
13944
+ this.credentialsValue = void 0;
13945
+ this.errorCodeValue = void 0;
13946
+ });
13947
+ }
13948
+ enqueue(operation) {
13949
+ const task = this.queue.then(operation, operation);
13950
+ this.queue = task.then(() => void 0, () => void 0);
13951
+ return task;
13952
+ }
13953
+ };
13954
+ //#endregion
13955
+ //#region src/caddy.ts
13956
+ /** Private directories override Caddy's platform defaults; ambient API keys and admin/proxy overrides are omitted. */
13957
+ function caddyProcessEnvironment(config, settings, environment = process.env) {
13958
+ const credentials = config.credentials();
13959
+ if (credentials === void 0) throw new Error("caddy_credentials_missing");
13960
+ return {
13961
+ ...caddyInspectionEnvironment(environment),
13962
+ ...caddyCredentialEnvironment(settings.dnsProvider, credentials),
13963
+ HOME: config.rootDirectory,
13964
+ USERPROFILE: config.rootDirectory,
13965
+ APPDATA: config.configDirectory,
13966
+ LOCALAPPDATA: config.dataDirectory,
13967
+ XDG_DATA_HOME: config.dataDirectory,
13968
+ XDG_CONFIG_HOME: config.configDirectory
13969
+ };
13970
+ }
13971
+ function spawnCaddy(executable, args, environment) {
13972
+ return spawn(executable, [...args], {
13973
+ env: environment,
13974
+ shell: false,
13975
+ stdio: [
13976
+ "pipe",
13977
+ "pipe",
13978
+ "pipe"
13979
+ ],
13980
+ windowsHide: true
13981
+ });
13982
+ }
13983
+ async function validate(executable, caddyfile, environment, signal) {
13984
+ try {
13985
+ await execFileText(executable, [
13986
+ "adapt",
13987
+ "--config",
13988
+ caddyfile,
13989
+ "--adapter",
13990
+ "caddyfile"
13991
+ ], {
13992
+ env: environment,
13993
+ signal,
13994
+ timeout: 15e3,
13995
+ maxBuffer: 262144
13996
+ });
13997
+ } catch {
13998
+ throw new Error("caddy_config_validation_failed");
13999
+ }
14000
+ }
14001
+ /** Owns Caddy and a loopback Gateway until both have stopped; ready requires verified public TLS and instance identity. */
14002
+ var CaddyController = class {
14003
+ options;
14004
+ enabled = false;
14005
+ initialized = false;
14006
+ disposed = false;
14007
+ child;
14008
+ gatewayValue;
14009
+ generation = 0;
14010
+ startupAbort;
14011
+ latest = Object.freeze({
14012
+ enabled: false,
14013
+ state: "off"
14014
+ });
14015
+ queue = Promise.resolve();
14016
+ closeTask;
14017
+ constructor(options) {
14018
+ this.options = options;
14019
+ }
14020
+ initialize() {
14021
+ return this.enqueue(async () => {
14022
+ if (this.initialized) return;
14023
+ if (this.disposed) throw new Error("caddy_controller_unavailable");
14024
+ this.enabled = (await this.options.store.load()).enabled;
14025
+ this.initialized = true;
14026
+ if (this.enabled) await this.start();
14027
+ });
14028
+ }
14029
+ gateway() {
14030
+ return this.latest.state === "ready" ? this.gatewayValue : void 0;
14031
+ }
14032
+ status() {
14033
+ return this.latest;
14034
+ }
14035
+ /** Cancel a readiness attempt before its owning coordinator waits for queued teardown. */
14036
+ cancelPendingStart() {
14037
+ this.startupAbort?.abort();
14038
+ }
14039
+ setEnabled(enabled) {
14040
+ if (!enabled) this.startupAbort?.abort();
14041
+ return this.enqueue(async () => {
14042
+ this.assertAvailable();
14043
+ if (this.enabled === enabled && (!enabled || this.latest.state === "ready")) return;
14044
+ if (!enabled) await this.stop();
14045
+ await this.options.store.save({
14046
+ version: 1,
14047
+ enabled
14048
+ });
14049
+ this.enabled = enabled;
14050
+ if (enabled) await this.start();
14051
+ else this.publish({
14052
+ enabled: false,
14053
+ state: "off"
14054
+ });
14055
+ }).then(() => this.status());
14056
+ }
14057
+ reconnect() {
14058
+ this.startupAbort?.abort();
14059
+ return this.enqueue(async () => {
14060
+ this.assertAvailable();
14061
+ await this.stop();
14062
+ await this.options.store.save({
14063
+ version: 1,
14064
+ enabled: true
14065
+ });
14066
+ this.enabled = true;
14067
+ await this.start();
14068
+ }).then(() => this.status());
14069
+ }
14070
+ reset() {
14071
+ return this.setEnabled(false);
14072
+ }
14073
+ close() {
14074
+ this.disposed = true;
14075
+ this.startupAbort?.abort();
14076
+ this.closeTask ??= this.enqueue(async () => {
14077
+ await this.stop();
14078
+ this.publish({
14079
+ enabled: this.enabled,
14080
+ state: "off"
14081
+ });
14082
+ }).catch((error) => {
14083
+ this.closeTask = void 0;
14084
+ throw error;
14085
+ });
14086
+ return this.closeTask;
14087
+ }
14088
+ assertAvailable() {
14089
+ if (!this.initialized || this.disposed) throw new Error("caddy_controller_unavailable");
14090
+ }
14091
+ enqueue(operation) {
14092
+ const task = this.queue.then(operation, operation);
14093
+ this.queue = task.then(() => void 0, () => void 0);
14094
+ return task;
14095
+ }
14096
+ publish(status) {
14097
+ this.latest = Object.freeze(status);
14098
+ try {
14099
+ this.options.onStatus?.(this.latest);
14100
+ } catch {}
14101
+ }
14102
+ async start() {
14103
+ const generation = ++this.generation;
14104
+ const startup = new AbortController();
14105
+ this.startupAbort = startup;
14106
+ const settings = this.options.config.settings();
14107
+ if (settings === void 0 || this.options.config.credentials() === void 0) {
14108
+ this.publish({
14109
+ enabled: true,
14110
+ state: "unavailable",
14111
+ errorCode: "caddy_config_missing"
14112
+ });
14113
+ this.startupAbort = void 0;
14114
+ return;
14115
+ }
14116
+ if (!this.options.component.status().supported) {
14117
+ this.publish({
14118
+ enabled: true,
14119
+ state: "unavailable",
14120
+ errorCode: "caddy_component_unavailable"
14121
+ });
14122
+ this.startupAbort = void 0;
14123
+ return;
14124
+ }
14125
+ this.publish({
14126
+ enabled: true,
14127
+ state: "starting",
14128
+ origin: settings.publicOrigin
14129
+ });
14130
+ let childFailure;
14131
+ const deadline = setTimeout(() => {
14132
+ startup.abort();
14133
+ }, this.options.startupTimeoutMs ?? 12e4);
14134
+ deadline.unref();
14135
+ try {
14136
+ await this.options.component.ensureExecutable();
14137
+ if (startup.signal.aborted || this.disposed) throw new Error("caddy_start_cancelled");
14138
+ const gateway = await this.options.createGateway(settings);
14139
+ this.gatewayValue = gateway;
14140
+ if (startup.signal.aborted || this.disposed) throw new Error("caddy_start_cancelled");
14141
+ await this.options.config.prepareCaddyfile(gateway.address().port);
14142
+ const environment = caddyProcessEnvironment(this.options.config, settings);
14143
+ await (this.options.validateConfig ?? validate)(this.options.component.executable, this.options.config.caddyfile, environment, startup.signal);
14144
+ if (startup.signal.aborted || this.disposed) throw new Error("caddy_start_cancelled");
14145
+ const child = (this.options.spawnProcess ?? spawnCaddy)(this.options.component.executable, [
14146
+ "run",
14147
+ "--config",
14148
+ this.options.config.caddyfile,
14149
+ "--adapter",
14150
+ "caddyfile"
14151
+ ], environment);
14152
+ this.child = child;
14153
+ child.stdout.resume();
14154
+ child.stderr.resume();
14155
+ child.stdin.end();
14156
+ const failed = (code) => {
14157
+ if (generation !== this.generation || this.child !== child) return;
14158
+ childFailure = code;
14159
+ startup.abort();
14160
+ if (this.latest.state === "ready") this.enqueue(async () => {
14161
+ if (generation !== this.generation) return;
14162
+ await this.stop();
14163
+ if (this.enabled && !this.disposed) this.publish({
14164
+ enabled: true,
14165
+ state: "error",
14166
+ errorCode: code
14167
+ });
14168
+ }).catch(() => {
14169
+ this.publish({
14170
+ enabled: this.enabled,
14171
+ state: "error",
14172
+ errorCode: "caddy_cleanup_failed"
14173
+ });
14174
+ });
14175
+ };
14176
+ child.once("error", () => {
14177
+ failed("caddy_launch_failed");
14178
+ });
14179
+ child.once("close", () => {
14180
+ failed("caddy_exited");
14181
+ });
14182
+ const probe = this.options.probeDiscovery ?? ((origin, expected, signal) => defaultProbeDiscovery({ origin }, expected, signal));
14183
+ while (true) {
14184
+ if (startup.signal.aborted || this.disposed) throw new Error(childFailure ?? "caddy_start_timeout");
14185
+ let ready = false;
14186
+ try {
14187
+ ready = await probe(settings.publicOrigin, this.options.instanceId, startup.signal);
14188
+ } catch (error) {
14189
+ if (error instanceof Error && (error.message === "frp_discovery_mismatch" || error.message === "frp_discovery_invalid")) throw new Error("caddy_public_identity_invalid");
14190
+ }
14191
+ if (startup.signal.aborted || this.disposed || generation !== this.generation) throw new Error(childFailure ?? "caddy_start_timeout");
14192
+ if (ready) break;
14193
+ await this.waitForProbe(startup.signal);
14194
+ }
14195
+ this.publish({
14196
+ enabled: true,
14197
+ state: "ready",
14198
+ origin: settings.publicOrigin,
14199
+ backendOrigin: "http://127.0.0.1:" + String(gateway.address().port)
14200
+ });
14201
+ } catch (error) {
14202
+ ++this.generation;
14203
+ await this.stopResources();
14204
+ if (!this.disposed) this.publish({
14205
+ enabled: this.enabled,
14206
+ state: "error",
14207
+ errorCode: childFailure ?? (error instanceof Error && error.message.startsWith("caddy_") ? error.message : error.code === "EADDRINUSE" ? "caddy_backend_port_in_use" : "caddy_start_failed")
14208
+ });
14209
+ } finally {
14210
+ clearTimeout(deadline);
14211
+ if (this.startupAbort === startup) this.startupAbort = void 0;
14212
+ }
14213
+ }
14214
+ waitForProbe(signal) {
14215
+ if (signal.aborted) return Promise.resolve();
14216
+ return new Promise((resolve) => {
14217
+ const done = () => {
14218
+ clearTimeout(timer);
14219
+ signal.removeEventListener("abort", done);
14220
+ resolve();
14221
+ };
14222
+ const timer = setTimeout(done, this.options.probeIntervalMs ?? 1e3);
14223
+ timer.unref();
14224
+ signal.addEventListener("abort", done, { once: true });
14225
+ });
14226
+ }
14227
+ async stop() {
14228
+ ++this.generation;
14229
+ this.startupAbort?.abort();
14230
+ await this.stopResources();
14231
+ }
14232
+ async stopResources() {
14233
+ const child = this.child;
14234
+ const gateway = this.gatewayValue;
14235
+ await settleRemoteResources([async () => {
14236
+ if (child !== void 0) {
14237
+ await (this.options.terminateProcess ?? terminateRemoteProcess)(child);
14238
+ if (this.child === child) this.child = void 0;
14239
+ }
14240
+ }, async () => {
14241
+ if (gateway !== void 0) {
14242
+ await gateway.close();
14243
+ if (this.gatewayValue === gateway) this.gatewayValue = void 0;
14244
+ }
14245
+ }], "caddy cleanup failed");
14246
+ }
14247
+ };
14248
+ //#endregion
14249
+ //#region src/managed-origin.ts
14250
+ /** Children never independently restore a durable enabled switch. */
14251
+ function createVolatileRemoteControlStore() {
14252
+ let value = {
14253
+ version: 1,
14254
+ enabled: false
14255
+ };
14256
+ return {
14257
+ load: async () => value,
14258
+ save: async (state) => {
14259
+ value = Object.freeze({ ...state });
14260
+ }
14261
+ };
14262
+ }
14263
+ function parseMode(value) {
14264
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("origin_mode_invalid");
14265
+ const record = value;
14266
+ if (record.version !== 1 || record.mode !== "external" && record.mode !== "managed" || Reflect.ownKeys(record).some((key) => key !== "version" && key !== "mode")) throw new Error("origin_mode_invalid");
14267
+ return Object.freeze({
14268
+ version: 1,
14269
+ mode: record.mode
14270
+ });
14271
+ }
14272
+ /** Persist only origin's upstream mode, defaulting to the existing external-proxy behavior. */
14273
+ var JsonOriginModeStore = class {
14274
+ file;
14275
+ constructor(file) {
14276
+ this.file = file;
14277
+ if (!isAbsolute(file)) throw new Error("origin mode file must be absolute");
14278
+ }
14279
+ async load() {
14280
+ await assertCaddyParents(dirname(this.file), this.file);
14281
+ let entry;
14282
+ try {
14283
+ entry = await lstat(this.file);
14284
+ } catch (error) {
14285
+ if (error.code === "ENOENT") return {
14286
+ version: 1,
14287
+ mode: "external"
14288
+ };
14289
+ throw error;
14290
+ }
14291
+ if (!entry.isFile() || entry.isSymbolicLink() || entry.size > 1024) throw new Error("origin_mode_invalid");
14292
+ await restrictPrivateFile(this.file);
14293
+ return parseMode(JSON.parse(await readFile(this.file, "utf8")));
14294
+ }
14295
+ async save(value) {
14296
+ await writeCaddyPrivateFile(dirname(this.file), this.file, JSON.stringify(parseMode(value)) + "\n");
14297
+ }
14298
+ };
14299
+ /** One origin-provider owner selects exactly one upstream; external settings and shared device records remain untouched. */
14300
+ var ManagedOriginController = class {
14301
+ options;
14302
+ selected = "external";
14303
+ enabled = false;
14304
+ initialized = false;
14305
+ disposed = false;
14306
+ queue = Promise.resolve();
14307
+ closeTask;
14308
+ constructor(options) {
14309
+ this.options = options;
14310
+ }
14311
+ mode() {
14312
+ return this.selected;
14313
+ }
14314
+ status() {
14315
+ return Object.freeze({
14316
+ ...this.controller().status(),
14317
+ enabled: this.enabled,
14318
+ mode: this.selected
14319
+ });
14320
+ }
14321
+ gateway() {
14322
+ return this.enabled ? this.controller().gateway() : void 0;
14323
+ }
14324
+ initialize() {
14325
+ return this.enqueue(async () => {
14326
+ if (this.initialized) return;
14327
+ if (this.disposed) throw new Error("origin_controller_unavailable");
14328
+ this.selected = (await this.options.modeStore.load()).mode;
14329
+ this.enabled = (await this.options.store.load()).enabled;
14330
+ await this.options.external.initialize();
14331
+ await this.options.managed.initialize();
14332
+ await this.options.external.setEnabled(false);
14333
+ await this.options.managed.setEnabled(false);
14334
+ this.initialized = true;
14335
+ if (this.enabled && !this.disposed) await this.controller().setEnabled(true);
14336
+ });
14337
+ }
14338
+ selectMode(mode) {
14339
+ if (mode !== this.selected) this.options.managed.cancelPendingStart?.();
14340
+ return this.enqueue(async () => {
14341
+ this.assertAvailable();
14342
+ if (mode !== "external" && mode !== "managed") throw new Error("origin_mode_invalid");
14343
+ if (mode === this.selected) return;
14344
+ const previous = this.controller();
14345
+ await previous.setEnabled(false);
14346
+ try {
14347
+ await this.options.modeStore.save({
14348
+ version: 1,
14349
+ mode
14350
+ });
14351
+ } catch (error) {
14352
+ if (this.enabled && !this.disposed) await previous.setEnabled(true);
14353
+ throw error;
14354
+ }
14355
+ this.selected = mode;
14356
+ if (this.enabled && !this.disposed) try {
14357
+ await this.controller().setEnabled(true);
14358
+ } catch (error) {
14359
+ await this.stopBoth();
14360
+ throw error;
14361
+ }
14362
+ }).then(() => this.status());
14363
+ }
14364
+ setEnabled(enabled) {
14365
+ if (!enabled) this.options.managed.cancelPendingStart?.();
14366
+ return this.enqueue(async () => {
14367
+ this.assertAvailable();
14368
+ if (!enabled) await this.stopBoth();
14369
+ await this.options.store.save({
14370
+ version: 1,
14371
+ enabled
14372
+ });
14373
+ this.enabled = enabled;
14374
+ if (enabled && !this.disposed) try {
14375
+ await this.controller().setEnabled(true);
14376
+ } catch (error) {
14377
+ await this.stopBoth();
14378
+ throw error;
14379
+ }
14380
+ }).then(() => this.status());
14381
+ }
14382
+ reconnect() {
14383
+ this.options.managed.cancelPendingStart?.();
14384
+ return this.enqueue(async () => {
14385
+ this.assertAvailable();
14386
+ await this.stopBoth();
14387
+ await this.options.store.save({
14388
+ version: 1,
14389
+ enabled: true
14390
+ });
14391
+ this.enabled = true;
14392
+ if (!this.disposed) try {
14393
+ await this.controller().reconnect();
14394
+ } catch (error) {
14395
+ await this.stopBoth();
14396
+ throw error;
14397
+ }
14398
+ }).then(() => this.status());
14399
+ }
14400
+ reset() {
14401
+ return this.setEnabled(false);
14402
+ }
14403
+ close() {
14404
+ this.disposed = true;
14405
+ this.closeTask ??= settleRemoteResources([() => this.options.external.close(), () => this.options.managed.close()]).then(() => this.queue).catch((error) => {
14406
+ this.closeTask = void 0;
14407
+ throw error;
14408
+ });
14409
+ return this.closeTask;
14410
+ }
14411
+ controller() {
14412
+ return this.options[this.selected];
14413
+ }
14414
+ assertAvailable() {
14415
+ if (!this.initialized || this.disposed) throw new Error("origin_controller_unavailable");
14416
+ }
14417
+ stopBoth() {
14418
+ return settleRemoteResources([async () => {
14419
+ await this.options.external.setEnabled(false);
14420
+ }, async () => {
14421
+ await this.options.managed.setEnabled(false);
14422
+ }]);
14423
+ }
14424
+ enqueue(operation) {
14425
+ const task = this.queue.then(operation, operation);
14426
+ this.queue = task.then(() => void 0, () => void 0);
14427
+ return task;
14428
+ }
14429
+ };
14430
+ //#endregion
14431
+ //#region src/release-update.ts
14432
+ const PACKAGE_NAME = "dsh-mobile";
14433
+ const NPM_LATEST_URL = "https://registry.npmjs.org/dsh-mobile/latest";
14434
+ const GITHUB_LATEST_URL = "https://github.com/saya-ch/dsh-mobile/releases/latest";
14435
+ const GITHUB_API_LATEST_URL = "https://api.github.com/repos/saya-ch/dsh-mobile/releases/latest";
14436
+ const GITHUB_RELEASES_URL = "https://github.com/saya-ch/dsh-mobile/releases";
14437
+ const RELEASE_NOTES_MAX_CHARS = 4e3;
14438
+ const STATUS_CACHE_MS = 6e5;
14439
+ const REQUEST_TIMEOUT_MS = 8e3;
14440
+ const UPDATE_TIMEOUT_MS = 12e4;
14441
+ const UPDATE_TERMINATION_GRACE_MS = 1500;
14442
+ const NUMERIC_VERSION_IDENTIFIER = "(?:0|[1-9]\\d*)";
14443
+ const WILDCARD_VERSION_IDENTIFIER = "(?:[xX*])";
14444
+ const RANGE_VERSION = `(?:${`${NUMERIC_VERSION_IDENTIFIER}\\.${NUMERIC_VERSION_IDENTIFIER}\\.${NUMERIC_VERSION_IDENTIFIER}(?:-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?`}|${`(?:${WILDCARD_VERSION_IDENTIFIER}|${NUMERIC_VERSION_IDENTIFIER}(?:\\.(?:${WILDCARD_VERSION_IDENTIFIER}|${NUMERIC_VERSION_IDENTIFIER}(?:\\.(?:${WILDCARD_VERSION_IDENTIFIER}|${NUMERIC_VERSION_IDENTIFIER}))?))?)`})`;
14445
+ const COMPARATOR = new RegExp(`^(?:<=|>=|<|>|=|~|\\^)?${RANGE_VERSION}$`, "u");
14446
+ const HYPHEN_RANGE = new RegExp(`^${RANGE_VERSION} +[-] +${RANGE_VERSION}$`, "u");
14447
+ const DIST_TAG = /^[A-Za-z][A-Za-z0-9._-]{0,127}$/u;
14448
+ function parseSemver(value) {
14449
+ const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/u.exec(value);
14450
+ if (match === null) return void 0;
14451
+ const core = [
14452
+ Number(match[1]),
14453
+ Number(match[2]),
14454
+ Number(match[3])
14455
+ ];
14456
+ if (core.some((part) => !Number.isSafeInteger(part))) return void 0;
14457
+ const prerelease = match[4] === void 0 ? [] : match[4].split(".").map((part) => /^\d+$/u.test(part) ? Number(part) : part);
14458
+ if (prerelease.some((part) => typeof part === "number" && !Number.isSafeInteger(part))) return void 0;
14459
+ return Object.freeze({
14460
+ core,
14461
+ prerelease: Object.freeze(prerelease)
14462
+ });
14463
+ }
14464
+ /** Compare two strict SemVer strings, including prerelease precedence. */
14465
+ function comparePluginVersions(left, right) {
14466
+ const a = parseSemver(left);
14467
+ const b = parseSemver(right);
14468
+ if (a === void 0 || b === void 0) return void 0;
14469
+ for (let index = 0; index < a.core.length; index += 1) {
14470
+ const difference = a.core[index] - b.core[index];
14471
+ if (difference !== 0) return Math.sign(difference);
14472
+ }
14473
+ if (a.prerelease.length === 0 || b.prerelease.length === 0) return a.prerelease.length === b.prerelease.length ? 0 : a.prerelease.length === 0 ? 1 : -1;
14474
+ const length = Math.max(a.prerelease.length, b.prerelease.length);
14475
+ for (let index = 0; index < length; index += 1) {
14476
+ const leftPart = a.prerelease[index];
14477
+ const rightPart = b.prerelease[index];
14478
+ if (leftPart === void 0 || rightPart === void 0) return leftPart === void 0 ? -1 : 1;
14479
+ if (leftPart === rightPart) continue;
14480
+ if (typeof leftPart === "number" && typeof rightPart === "number") return Math.sign(leftPart - rightPart);
14481
+ if (typeof leftPart === "number") return -1;
14482
+ if (typeof rightPart === "number") return 1;
14483
+ return leftPart < rightPart ? -1 : 1;
14484
+ }
14485
+ return 0;
14486
+ }
14487
+ function isComparatorSet(value) {
14488
+ if (HYPHEN_RANGE.test(value)) return true;
14489
+ const comparators = value.replace(/(<=|>=|<|>|=|~|\^) +/gu, "$1").split(/ +/u);
14490
+ return comparators.length > 0 && comparators.every((comparator) => COMPARATOR.test(comparator));
14491
+ }
14492
+ function isNpmVersionRange(value) {
14493
+ if (!/^[0-9xX*<>=~^|.+\- ]+$/u.test(value)) return false;
14494
+ const alternatives = value.split(/ *\|\| */u);
14495
+ return alternatives.length > 0 && alternatives.every((alternative) => alternative !== "" && isComparatorSet(alternative));
14496
+ }
14497
+ /** Return whether pnpm may safely replace this profile dependency from an npm version, range, or tag. */
14498
+ function isRegistryPluginSpec(value) {
14499
+ if (typeof value !== "string" || value.trim() !== value || value === "" || /[\u0000-\u001f\u007f]/u.test(value)) return false;
14500
+ if (/\.(?:tgz|tar(?:\.gz)?)$/iu.test(value)) return false;
14501
+ return parseSemver(value) !== void 0 || isNpmVersionRange(value) || DIST_TAG.test(value);
14502
+ }
14503
+ /** Resolve the DSH profile named by the current launcher arguments. */
14504
+ function launchedProfileName(argv) {
14505
+ for (let index = 0; index < argv.length; index += 1) {
14506
+ if (argv[index] === "--profile") {
14507
+ const candidate = argv[index + 1];
14508
+ if (candidate !== void 0 && /^[\w.-]+$/u.test(candidate)) return candidate;
14509
+ }
14510
+ const match = /^--profile=([\w.-]+)$/u.exec(argv[index] ?? "");
14511
+ if (match?.[1] !== void 0) return match[1];
14512
+ }
14513
+ return "web";
14514
+ }
14515
+ /** Resolve the launcher-owned Desktop profile, or the CLI profile outside Desktop. */
14516
+ function releaseProfileDirectory(ctx, dshHome, argv) {
14517
+ const desktopProfiles = ctx.get("desktopProfiles");
14518
+ const desktopDirectory = desktopProfiles?.current?.dir;
14519
+ if (typeof desktopDirectory === "string" && isAbsolute(desktopDirectory)) return desktopDirectory;
14520
+ if (desktopProfiles !== void 0 || ctx.get("desktopRuntime") !== void 0) return void 0;
14521
+ return join(dshHome, "profiles", launchedProfileName(argv));
14522
+ }
14523
+ async function profileDependencySpec(profileDirectory) {
14524
+ try {
14525
+ const value = JSON.parse(await readFile(join(profileDirectory, "package.json"), "utf8")).dependencies?.[PACKAGE_NAME];
14526
+ return typeof value === "string" ? value : void 0;
14527
+ } catch {
14528
+ return;
14529
+ }
14530
+ }
14531
+ async function fetchNpmVersion(fetcher) {
14532
+ const response = await fetcher(NPM_LATEST_URL, {
14533
+ headers: {
14534
+ accept: "application/json",
14535
+ "user-agent": "dsh-mobile-release-check"
14536
+ },
14537
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
14538
+ });
14539
+ if (!response.ok) return void 0;
14540
+ const payload = await response.json();
14541
+ return typeof payload.version === "string" && parseSemver(payload.version) !== void 0 ? payload.version : void 0;
14542
+ }
14543
+ function githubReleaseVersion(location, responseUrl) {
14544
+ let url;
14545
+ try {
14546
+ url = new URL(location ?? responseUrl, GITHUB_LATEST_URL);
14547
+ } catch {
14548
+ return;
14549
+ }
14550
+ if (url.origin !== "https://github.com" || url.username !== "" || url.password !== "" || url.search !== "" || url.hash !== "") return void 0;
14551
+ if (!url.pathname.startsWith("/saya-ch/dsh-mobile/releases/tag/v")) return void 0;
14552
+ let version;
14553
+ try {
14554
+ version = decodeURIComponent(url.pathname.slice(34));
14555
+ } catch {
14556
+ return;
14557
+ }
14558
+ return parseSemver(version) === void 0 ? void 0 : version;
14559
+ }
14560
+ function androidReleaseDownloadUrl(version) {
14561
+ if (version === void 0) return GITHUB_RELEASES_URL;
14562
+ const tag = `v${version}`;
14563
+ return `https://github.com/saya-ch/dsh-mobile/releases/download/${encodeURIComponent(tag)}/dsh-mobile-android-${encodeURIComponent(tag)}.apk`;
14564
+ }
14565
+ async function fetchAndroidVersion(fetcher) {
14566
+ const response = await fetcher(GITHUB_LATEST_URL, {
14567
+ method: "GET",
14568
+ redirect: "manual",
14569
+ headers: {
14570
+ accept: "text/html",
14571
+ "user-agent": "dsh-mobile-release-check"
14572
+ },
14573
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
14574
+ });
14575
+ return githubReleaseVersion(response.headers.get("location"), response.url);
14576
+ }
14577
+ /** Best-effort body of the latest GitHub release, trimmed to a bounded size. */
14578
+ async function fetchReleaseNotes(fetcher) {
14579
+ const response = await fetcher(GITHUB_API_LATEST_URL, {
14580
+ headers: {
14581
+ accept: "application/vnd.github+json",
14582
+ "user-agent": "dsh-mobile-release-check"
14583
+ },
14584
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
14585
+ });
14586
+ if (!response.ok) return void 0;
14587
+ const payload = await response.json();
14588
+ if (typeof payload.body !== "string") return void 0;
14589
+ const notes = payload.body.trim();
14590
+ return notes === "" ? void 0 : notes.slice(0, RELEASE_NOTES_MAX_CHARS);
14591
+ }
14592
+ async function readProfileInstalledVersion(profileDirectory) {
14593
+ try {
14594
+ const manifestPath = createRequire(join(profileDirectory, "package.json")).resolve(`${PACKAGE_NAME}/package.json`);
14595
+ const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
14596
+ return typeof manifest.version === "string" ? manifest.version : void 0;
14597
+ } catch {
14598
+ return;
14599
+ }
14600
+ }
14601
+ function childCompletion(child) {
14602
+ return new Promise((resolveCompletion, rejectCompletion) => {
14603
+ child.once("error", rejectCompletion);
14604
+ child.once("close", (code, signal) => {
14605
+ resolveCompletion({
14606
+ code,
14607
+ signal
14608
+ });
14609
+ });
14610
+ });
14611
+ }
14612
+ function createDeadline(timeoutMs) {
14613
+ let timer;
14614
+ return {
14615
+ promise: new Promise((resolveTimeout) => {
14616
+ timer = setTimeout(resolveTimeout, timeoutMs);
14617
+ timer.unref();
14618
+ }),
14619
+ cancel: () => {
14620
+ if (timer !== void 0) clearTimeout(timer);
14621
+ timer = void 0;
14622
+ }
14623
+ };
14624
+ }
14625
+ async function taskkillProcessTree(pid) {
12939
14626
  if ((await childCompletion(spawn("taskkill.exe", [
12940
14627
  "/PID",
12941
14628
  String(pid),
@@ -13434,7 +15121,8 @@ async function loadSetup(config) {
13434
15121
  if (record.version === 2) return {
13435
15122
  kind: "managed",
13436
15123
  config: withoutSetupKeys(config),
13437
- setup: parseManagedSetup(record)
15124
+ setup: parseManagedSetup(record),
15125
+ setupFile: resolve(config.setupFile)
13438
15126
  };
13439
15127
  if (record.version !== 1 || Reflect.ownKeys(record).some((key) => typeof key !== "string" || !SETUP_KEYS.has(key))) throw new Error("mobile setup file has an unsupported format");
13440
15128
  const { version: _version, ...setup } = record;
@@ -13542,7 +15230,7 @@ function originGatewayConfig(template, settings, stateFile, instanceId, listenPo
13542
15230
  allowedCidrs: Object.freeze(validated.allowedCidrs.map(parseCidr))
13543
15231
  });
13544
15232
  }
13545
- function remoteControlPayload(provider, status, gateway, providerStatuses, cpolarComponent, cloudflaredComponent, cloudflaredConfiguration, frpComponent, frpConfiguration, originConfiguration) {
15233
+ function remoteControlPayload(provider, status, gateway, providerStatuses, cpolarComponent, cloudflaredComponent, cloudflaredConfiguration, frpComponent, frpConfiguration, originConfiguration, caddyComponentStatus) {
13546
15234
  return {
13547
15235
  provider,
13548
15236
  running: status.enabled,
@@ -13583,7 +15271,8 @@ function remoteControlPayload(provider, status, gateway, providerStatuses, cpola
13583
15271
  bundled: true,
13584
15272
  running: providerStatuses.origin.enabled,
13585
15273
  state: providerStatuses.origin.state,
13586
- configuration: originConfiguration
15274
+ configuration: originConfiguration,
15275
+ component: caddyComponentStatus
13587
15276
  }
13588
15277
  }
13589
15278
  };
@@ -13623,6 +15312,10 @@ async function apply(ctx, config) {
13623
15312
  await frpConfig.initialize();
13624
15313
  const originConfig = new OriginConfigStore(join(remoteDirectory, "origin", "config"));
13625
15314
  await originConfig.initialize();
15315
+ const caddyComponent = new CaddyComponentManager({ stateDirectory });
15316
+ await caddyComponent.initialize();
15317
+ const caddyConfig = new CaddyConfigStore(stateDirectory);
15318
+ await caddyConfig.initialize();
13626
15319
  const cloudflaredTunnel = new CloudflaredTunnelStore(join(remoteDirectory, "cloudflared"));
13627
15320
  await cloudflaredTunnel.initialize();
13628
15321
  const unregisterBuiltin = mobileAccess.registerExtension({
@@ -13658,8 +15351,13 @@ async function apply(ctx, config) {
13658
15351
  const webSocketPaths = new WebSocketPathStore(join(stateDirectory, "websocket-paths.json"));
13659
15352
  await webSocketPaths.load();
13660
15353
  const blockedUpgradePaths = new BlockedUpgradePathLog();
15354
+ const clientModulePreferences = new ClientModulePreferenceStore(join(stateDirectory, "client-modules.json"), template.excludedClientModules);
15355
+ await clientModulePreferences.initialize();
13661
15356
  let lanGateway;
13662
15357
  let preparedLanSetup;
15358
+ let trustedNetworksRestartRequired = false;
15359
+ let trustedNetworksWrite = Promise.resolve();
15360
+ let adminClosing = false;
13663
15361
  const startGateway = async (candidateConfig) => {
13664
15362
  const resolved = parseGatewayConfig({
13665
15363
  ...candidateConfig,
@@ -13667,7 +15365,7 @@ async function apply(ctx, config) {
13667
15365
  });
13668
15366
  const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths, (source, code) => {
13669
15367
  logger.warn("%s discovery degraded while DSH remains available: %s", source, code);
13670
- });
15368
+ }, clientModulePreferences);
13671
15369
  await candidate.start();
13672
15370
  lanGateway = candidate;
13673
15371
  const removeTaskSink = taskEventHub.add(candidate);
@@ -13723,7 +15421,7 @@ async function apply(ctx, config) {
13723
15421
  }
13724
15422
  const createRemoteGateway = async (publicOrigin, listenPort = 0) => {
13725
15423
  const resolved = remoteGatewayConfig(template, publicOrigin, remoteDeviceFile, instanceId, listenPort);
13726
- const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths);
15424
+ const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths, void 0, clientModulePreferences);
13727
15425
  await candidate.start();
13728
15426
  return candidate;
13729
15427
  };
@@ -13739,7 +15437,7 @@ async function apply(ctx, config) {
13739
15437
  if (!isFrpSelfSignedIngress(settings)) return createRemoteGateway(publicOrigin);
13740
15438
  const ingress = await ensureFrpIngressCertificate(settings, remoteDeviceFile);
13741
15439
  const resolved = frpIngressGatewayConfig(template, settings, remoteDeviceFile, ingress);
13742
- const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths);
15440
+ const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths, void 0, clientModulePreferences);
13743
15441
  try {
13744
15442
  await candidate.start();
13745
15443
  } catch (error) {
@@ -13750,7 +15448,7 @@ async function apply(ctx, config) {
13750
15448
  };
13751
15449
  const createOriginGateway = async (settings) => {
13752
15450
  const resolved = originGatewayConfig(template, settings, remoteDeviceFile, instanceId);
13753
- const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths);
15451
+ const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths, void 0, clientModulePreferences);
13754
15452
  try {
13755
15453
  await candidate.start();
13756
15454
  } catch (error) {
@@ -13764,6 +15462,23 @@ async function apply(ctx, config) {
13764
15462
  const cloudflaredStore = new JsonMobileAccessControlStore(join(remoteDirectory, "cloudflared", "control.json"), false);
13765
15463
  const frpStore = new JsonMobileAccessControlStore(join(remoteDirectory, "frp", "control.json"), false);
13766
15464
  const originStore = new JsonMobileAccessControlStore(join(remoteDirectory, "origin", "control.json"), false);
15465
+ const caddyController = new CaddyController({
15466
+ store: createVolatileRemoteControlStore(),
15467
+ component: caddyComponent,
15468
+ config: caddyConfig,
15469
+ instanceId,
15470
+ createGateway: (settings) => createRemoteGateway(settings.publicOrigin)
15471
+ });
15472
+ const originController = new ManagedOriginController({
15473
+ store: originStore,
15474
+ modeStore: new JsonOriginModeStore(join(remoteDirectory, "origin", "upstream.json")),
15475
+ external: new OriginController({
15476
+ store: createVolatileRemoteControlStore(),
15477
+ config: originConfig,
15478
+ createGateway: createOriginGateway
15479
+ }),
15480
+ managed: caddyController
15481
+ });
13767
15482
  const remoteControllers = {
13768
15483
  tailscale: new FunnelController({
13769
15484
  store: tailscaleStore,
@@ -13802,11 +15517,7 @@ async function apply(ctx, config) {
13802
15517
  await gateway.refreshProvidedTls();
13803
15518
  }
13804
15519
  }),
13805
- origin: new OriginController({
13806
- store: originStore,
13807
- config: originConfig,
13808
- createGateway: createOriginGateway
13809
- })
15520
+ origin: originController
13810
15521
  };
13811
15522
  const remoteProviders = new RemoteProviderCoordinator(initialRemoteProvider, remoteControllers, remoteProviderStore);
13812
15523
  const remoteController = () => remoteProviders.controller();
@@ -13816,13 +15527,18 @@ async function apply(ctx, config) {
13816
15527
  controller.gateway()?.broadcastTaskEvent(event);
13817
15528
  } });
13818
15529
  }
13819
- const remotePayload = () => remoteControlPayload(remoteProviders.selected, remoteController().status(), remoteController().gateway(), {
13820
- tailscale: remoteControllers.tailscale.status(),
13821
- cpolar: remoteControllers.cpolar.status(),
13822
- cloudflared: remoteControllers.cloudflared.status(),
13823
- frp: remoteControllers.frp.status(),
13824
- origin: remoteControllers.origin.status()
13825
- }, cpolarComponent.status(), cloudflaredComponent.status(), cloudflaredTunnel.status(), frpComponent.status(), frpConfig.status(), originConfig.status());
15530
+ const remotePayload = () => ({
15531
+ ...remoteControlPayload(remoteProviders.selected, remoteController().status(), remoteController().gateway(), {
15532
+ tailscale: remoteControllers.tailscale.status(),
15533
+ cpolar: remoteControllers.cpolar.status(),
15534
+ cloudflared: remoteControllers.cloudflared.status(),
15535
+ frp: remoteControllers.frp.status(),
15536
+ origin: remoteControllers.origin.status()
15537
+ }, cpolarComponent.status(), cloudflaredComponent.status(), cloudflaredTunnel.status(), frpComponent.status(), frpConfig.status(), originConfig.status(), caddyComponent.status()),
15538
+ originMode: originController.mode(),
15539
+ caddyConfiguration: caddyConfig.status(),
15540
+ caddyState: caddyController.status()
15541
+ });
13826
15542
  const lanPayload = () => ({
13827
15543
  ...loaded.kind === "unconfigured" ? {
13828
15544
  configured: preparedLanSetup !== void 0,
@@ -13853,6 +15569,15 @@ async function apply(ctx, config) {
13853
15569
  windowsFirewall: process.platform === "win32"
13854
15570
  };
13855
15571
  };
15572
+ const trustedNetworksPayload = async () => {
15573
+ if (loaded.kind !== "managed") return { supported: false };
15574
+ return {
15575
+ supported: true,
15576
+ extraAllowedCidrs: parseManagedSetup(JSON.parse(await readFile(loaded.setupFile, "utf8"))).extraAllowedCidrs ?? [],
15577
+ restartRequired: trustedNetworksRestartRequired,
15578
+ windowsFirewall: process.platform === "win32"
15579
+ };
15580
+ };
13856
15581
  const diagnosticsPayload = async () => {
13857
15582
  let interfaceName;
13858
15583
  let networkError;
@@ -13918,6 +15643,7 @@ async function apply(ctx, config) {
13918
15643
  try {
13919
15644
  const target = parseRequestTarget(request.url);
13920
15645
  assertLocalAdminTrust(request, request.method === "POST", () => authenticateDesktopAdmin(request));
15646
+ if (adminClosing) throw new HttpError(503, "unavailable");
13921
15647
  if (target.search !== "") throw new HttpError(400, "bad_request");
13922
15648
  const lanControl = target.decodedPathname === `/api/mobile-access/control` || target.decodedPathname === `/api/mobile-access/lan/control`;
13923
15649
  if (request.method === "GET" && lanControl) {
@@ -13928,6 +15654,59 @@ async function apply(ctx, config) {
13928
15654
  sendJson(response, 200, await lanSetupPayload(), false);
13929
15655
  return;
13930
15656
  }
15657
+ if (target.decodedPathname === `/api/mobile-access/client-modules`) {
15658
+ const gateway = lanGateway ?? remoteController().gateway();
15659
+ if (gateway === void 0) throw new HttpError(409, "mobile_access_not_running");
15660
+ const operation = new AbortController();
15661
+ const abort = () => {
15662
+ operation.abort();
15663
+ };
15664
+ response.once("close", abort);
15665
+ try {
15666
+ if (request.method === "GET") {
15667
+ sendJson(response, 200, await gateway.describeClientModules(void 0, operation.signal), false);
15668
+ return;
15669
+ }
15670
+ if (request.method !== "POST") throw new HttpError(405, "method_not_allowed");
15671
+ const body = await readJsonObject(request, template.maxBodyBytes);
15672
+ if (Object.keys(body).length !== 1 || Object.keys(body).some((key) => key !== "reset" && key !== "excludedClientModules")) throw new HttpError(400, "bad_request");
15673
+ sendJson(response, 200, body.reset === true ? await gateway.resetClientModules(void 0, operation.signal) : await gateway.configureClientModules(body.excludedClientModules, void 0, operation.signal), false);
15674
+ } catch (error) {
15675
+ if (!(error instanceof ClientModuleConflictError)) throw error;
15676
+ sendJson(response, 409, {
15677
+ error: "excluded_client_modules_invalid",
15678
+ detail: error.detail
15679
+ }, false);
15680
+ } finally {
15681
+ response.removeListener("close", abort);
15682
+ operation.abort();
15683
+ }
15684
+ return;
15685
+ }
15686
+ if (target.decodedPathname === `/api/mobile-access/lan/trusted-networks`) {
15687
+ if (request.method === "GET") {
15688
+ sendJson(response, 200, await trustedNetworksPayload(), false);
15689
+ return;
15690
+ }
15691
+ if (request.method !== "POST") throw new HttpError(405, "method_not_allowed");
15692
+ if (loaded.kind !== "managed") throw new HttpError(409, "lan_trusted_networks_unavailable");
15693
+ const body = await readJsonObject(request, 4096);
15694
+ let values;
15695
+ try {
15696
+ values = parseAdditionalTrustedNetworks(body.extraAllowedCidrs);
15697
+ } catch {
15698
+ throw new HttpError(400, "lan_trusted_networks_invalid");
15699
+ }
15700
+ const write = trustedNetworksWrite.then(async () => {
15701
+ if (adminClosing) throw new HttpError(503, "unavailable");
15702
+ await saveManagedTrustedNetworks(loaded.setupFile, values);
15703
+ trustedNetworksRestartRequired = true;
15704
+ });
15705
+ trustedNetworksWrite = write.catch(() => void 0);
15706
+ await write;
15707
+ sendJson(response, 200, await trustedNetworksPayload(), false);
15708
+ return;
15709
+ }
13931
15710
  if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/diagnostics`) {
13932
15711
  sendJson(response, 200, await diagnosticsPayload(), false);
13933
15712
  return;
@@ -14055,6 +15834,63 @@ async function apply(ctx, config) {
14055
15834
  sendJson(response, 200, remotePayload(), false);
14056
15835
  return;
14057
15836
  }
15837
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/caddy/component/install`) {
15838
+ if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
15839
+ logger.info("caddy component install started");
15840
+ try {
15841
+ await remoteProviders.mutate(async () => caddyComponent.install());
15842
+ logger.info("caddy component install completed");
15843
+ } catch (error) {
15844
+ logger.error("caddy component install failed: %s", error instanceof Error ? error.stack ?? error.message : String(error));
15845
+ throw error;
15846
+ }
15847
+ sendJson(response, 200, remotePayload(), false);
15848
+ return;
15849
+ }
15850
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/caddy/settings`) {
15851
+ const body = await readJsonObject(request, 8192);
15852
+ if (body.connect !== void 0 && typeof body.connect !== "boolean") throw new HttpError(400, "bad_request");
15853
+ const configure = async () => {
15854
+ if (body.connect === true && !caddyComponent.status().installed) throw new HttpError(409, "caddy_component_unavailable");
15855
+ await caddyConfig.configure(body.settings, {
15856
+ secretId: body.secretId ?? "",
15857
+ secretKey: body.secretKey ?? ""
15858
+ });
15859
+ if (body.connect === true) {
15860
+ await originController.setEnabled(false);
15861
+ await originController.selectMode("managed");
15862
+ await originController.reconnect();
15863
+ } else if (originController.mode() === "managed" && originController.status().enabled) await originController.reconnect();
15864
+ };
15865
+ if (body.connect === true) await remoteProviders.mutateSelected("origin", configure);
15866
+ else await remoteProviders.mutate(configure);
15867
+ sendJson(response, 200, remotePayload(), false);
15868
+ return;
15869
+ }
15870
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/origin/mode`) {
15871
+ const body = await readJsonObject(request, 4096);
15872
+ if (body.mode !== "external" && body.mode !== "managed") throw new HttpError(400, "bad_request");
15873
+ const mode = body.mode;
15874
+ if (mode === "managed" && !caddyComponent.status().installed) throw new HttpError(409, "caddy_component_unavailable");
15875
+ await remoteProviders.mutate(async () => {
15876
+ await originController.selectMode(mode);
15877
+ });
15878
+ sendJson(response, 200, remotePayload(), false);
15879
+ return;
15880
+ }
15881
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/caddy/component/purge`) {
15882
+ if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
15883
+ await remoteProviders.mutate(async () => {
15884
+ if (originController.mode() === "managed") {
15885
+ await originController.setEnabled(false);
15886
+ await originController.selectMode("external");
15887
+ }
15888
+ await caddyComponent.purge();
15889
+ await caddyConfig.purge();
15890
+ });
15891
+ sendJson(response, 200, remotePayload(), false);
15892
+ return;
15893
+ }
14058
15894
  if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/cloudflared/tunnel`) {
14059
15895
  const body = await readJsonObject(request, 8192);
14060
15896
  await remoteProviders.mutate(async () => {
@@ -14113,7 +15949,8 @@ async function apply(ctx, config) {
14113
15949
  const settings = frpConfig.settings();
14114
15950
  if (settings === void 0) throw new HttpError(409, "frp_config_missing");
14115
15951
  const check = await frpIngressSelfCheck(settings, remoteDeviceFile);
14116
- const [frpsReachable, entryReachable] = await Promise.all([probeTcpReachable(settings.serverAddress, settings.serverPort), probeTcpReachable(settings.serverAddress, resolveFrpPublicPort(settings))]);
15952
+ const entry = resolveFrpEntryProbe(settings);
15953
+ const [frpsReachable, entryReachable] = await Promise.all([probeTcpReachable(settings.serverAddress, settings.serverPort), probeTcpReachable(entry.host, entry.port)]);
14117
15954
  sendJson(response, 200, {
14118
15955
  ...remotePayload(),
14119
15956
  frpSelfCheck: {
@@ -14281,6 +16118,10 @@ async function apply(ctx, config) {
14281
16118
  };
14282
16119
  await ctx.effect(async () => {
14283
16120
  const unregister = ctx.webServer.register(adminRoute);
16121
+ const stopAdmin = () => {
16122
+ adminClosing = true;
16123
+ unregister();
16124
+ };
14284
16125
  const disposeMobileCommand = ctx.commands.register({
14285
16126
  name: "mobile",
14286
16127
  description: "定制手机界面或添加电脑能力",
@@ -14337,7 +16178,8 @@ async function apply(ctx, config) {
14337
16178
  } catch (error) {
14338
16179
  try {
14339
16180
  await settleCleanupSteps([
14340
- unregister,
16181
+ stopAdmin,
16182
+ () => trustedNetworksWrite,
14341
16183
  disposeMobileCommand,
14342
16184
  disposeTaskEvents,
14343
16185
  async () => {
@@ -14355,7 +16197,8 @@ async function apply(ctx, config) {
14355
16197
  }
14356
16198
  return async () => {
14357
16199
  await settleCleanupSteps([
14358
- unregister,
16200
+ stopAdmin,
16201
+ () => trustedNetworksWrite,
14359
16202
  disposeMobileCommand,
14360
16203
  disposeTaskEvents,
14361
16204
  async () => {
@@ -14370,6 +16213,6 @@ async function apply(ctx, config) {
14370
16213
  }, "dsh-mobile: independent LAN and selectable remote providers with /mobile command");
14371
16214
  }
14372
16215
  //#endregion
14373
- export { AUTH_PREFIX, AccessController, AccessError, BlockedUpgradePathLog, BoundedRateLimiter, CERT_EXPIRING_DAYS, CLOUDFLARED_COMPONENT_RELEASE, CLOUDFLARED_COMPONENT_RELEASES, CSRF_COOKIE, CSRF_HEADER, CloudflaredComponentManager, CloudflaredController, CloudflaredTunnelStore, Config, DEFAULT_ORIGIN_LISTEN_PORT, FRP_VHOST_HTTP_PORT as DEFAULT_VHOST_HTTP_PORT, FRP_VHOST_HTTP_PORT, DEVICE_COOKIE, EXTENSION_LIMITS, FRP_ATTACH_DISCOVERY_PATH, FRP_ATTACH_LOCAL_DECLARATION, FRP_ATTACH_SELF_SIGNED_DECLARATION, FRP_ATTACH_VPS_DECLARATION, FRP_CADDY_IMPORT_LINE, FRP_CADDY_SNIPPET_MARKER, FRP_CADDY_SNIPPET_PATH, FRP_COMPONENT_RELEASES, FRP_DEFAULT_PROXY_NAME, FRP_DEFAULT_PUBLIC_PORT, FRP_RESERVED_PORTS, FrpComponentManager, FrpConfigStore, FrpController, JsonDeviceStore, JsonMobileAccessControlStore, JsonRemoteProviderStore, LOCAL_ADMIN_PREFIX, MAX_BLOCKED_UPGRADE_PATHS, MAX_EXTRA_WEBSOCKET_PATHS, MAX_WEBSOCKET_PATH_LENGTH, MemoryDeviceStore, MobileAccessGateway, MobileAccessGatewayController, MobileAccessService, MobileExtensionError, OriginConfigStore, OriginController, REMOTE_PROVIDERS, RequestTrustPolicy, SESSION_COOKIE, TASK_EVENT_DEBOUNCE_MS, TaskEventHub, WS_PATHS, WebSocketPathStore, addressAllowed, apply, assertExtensionId, configuredRemoteProvider, createCaddySite, createFrpAttachPlan, createFrpAttachTemplate, createFrpAttachTemplateParts, createFrpServerTemplate, createFrpcToml, createMobileAccessService, createRestrictedFrpServerTemplate, ensureFrpIngressCertificate, evaluateCertificateLifetime, frpAttachVpsParts, frpIngressPaths, frpIngressSelfCheck, frpProxyName, inject, isCloudflaredRegistration, isFrpSelfSignedIngress, isGloballyRoutableIpv4, isLoopbackAddress, issueServerCertificate, mergeSavedCloudflaredTunnelSettings, mergeSavedFrpSettings, mergeSavedFrpTarget, name, normalizeWebSocketPaths, originGatewayConfig, parseAuthority, parseCidr, parseCloudflaredOrigin, parseCloudflaredTunnelSettings, parseControlFile, parseDeviceSnapshot, parseExtensionManifest, parseFrpSettings, parseGatewayConfig, parseMobileAccessControlState, parseOriginSettings, parseRemoteProviderState, probeOriginCertificate, readCertificateRenewal, resolveAuthority, resolveFrpEntryTls, resolveFrpMode, resolveFrpPublicPort, resolveFrpVhostHttpPort, rewriteMobileIndex, validateAttachSettings, validateCloudflaredTunnelHostname, validateCloudflaredTunnelPort, validateCloudflaredTunnelToken, validateFrpEntryTls, validateFrpMode, validateFrpProxyName, validateFrpPublicOrigin, validateFrpPublicPort, validateFrpServerAddress, validateFrpServerPort, validateFrpToken, validateFrpVhostHttpPort, validateOriginAllowedCidrs, validateOriginListenHost, validateOriginListenPort, validateOriginPublicOrigin, validateWebSocketPath, watchTaskCompletions };
16216
+ export { AUTH_PREFIX, AccessController, AccessError, BlockedUpgradePathLog, BoundedRateLimiter, CERT_EXPIRING_DAYS, CLOUDFLARED_COMPONENT_RELEASE, CLOUDFLARED_COMPONENT_RELEASES, CSRF_COOKIE, CSRF_HEADER, ClientModuleConflictError, ClientModulePreferenceStore, CloudflaredComponentManager, CloudflaredController, CloudflaredTunnelStore, Config, DEFAULT_ORIGIN_LISTEN_PORT, FRP_VHOST_HTTP_PORT as DEFAULT_VHOST_HTTP_PORT, FRP_VHOST_HTTP_PORT, DEVICE_COOKIE, EXTENSION_LIMITS, FRP_ATTACH_DISCOVERY_PATH, FRP_ATTACH_LOCAL_DECLARATION, FRP_ATTACH_SELF_SIGNED_DECLARATION, FRP_ATTACH_VPS_DECLARATION, FRP_CADDY_IMPORT_LINE, FRP_CADDY_SNIPPET_MARKER, FRP_CADDY_SNIPPET_PATH, FRP_COMPONENT_RELEASES, FRP_DEFAULT_PROXY_NAME, FRP_DEFAULT_PUBLIC_PORT, FRP_RESERVED_PORTS, FrpComponentManager, FrpConfigStore, FrpController, JsonDeviceStore, JsonMobileAccessControlStore, JsonRemoteProviderStore, LOCAL_ADMIN_PREFIX, MAX_BLOCKED_UPGRADE_PATHS, MAX_EXTRA_WEBSOCKET_PATHS, MAX_WEBSOCKET_PATH_LENGTH, MemoryDeviceStore, MobileAccessGateway, MobileAccessGatewayController, MobileAccessService, MobileExtensionError, OriginConfigStore, OriginController, REMOTE_PROVIDERS, RequestTrustPolicy, SESSION_COOKIE, TASK_EVENT_DEBOUNCE_MS, TaskEventHub, WS_PATHS, WebSocketPathStore, addressAllowed, apply, assertExtensionId, configuredRemoteProvider, createCaddySite, createFrpAttachPlan, createFrpAttachTemplate, createFrpAttachTemplateParts, createFrpServerTemplate, createFrpcToml, createMobileAccessService, createRestrictedFrpServerTemplate, ensureFrpIngressCertificate, evaluateCertificateLifetime, frpAttachVpsParts, frpIngressPaths, frpIngressSelfCheck, frpProxyName, inject, isCloudflaredRegistration, isFrpSelfSignedIngress, isGloballyRoutableIpv4, isLoopbackAddress, issueServerCertificate, mergeSavedCloudflaredTunnelSettings, mergeSavedFrpSettings, mergeSavedFrpTarget, name, normalizeWebSocketPaths, originGatewayConfig, parseAuthority, parseCidr, parseCloudflaredOrigin, parseCloudflaredTunnelSettings, parseControlFile, parseDeviceSnapshot, parseExtensionManifest, parseFrpSettings, parseGatewayConfig, parseMobileAccessControlState, parseOriginSettings, parseRemoteProviderState, probeOriginCertificate, readCertificateRenewal, resolveAuthority, resolveFrpEntryTls, resolveFrpMode, resolveFrpPublicPort, resolveFrpVhostHttpPort, rewriteMobileIndex, validateAttachSettings, validateCloudflaredTunnelHostname, validateCloudflaredTunnelPort, validateCloudflaredTunnelToken, validateFrpEntryTls, validateFrpMode, validateFrpProxyName, validateFrpPublicOrigin, validateFrpPublicPort, validateFrpServerAddress, validateFrpServerPort, validateFrpToken, validateFrpVhostHttpPort, validateOriginAllowedCidrs, validateOriginListenHost, validateOriginListenPort, validateOriginPublicOrigin, validateWebSocketPath, watchTaskCompletions };
14374
16217
 
14375
16218
  //# sourceMappingURL=index.mjs.map