dsh-mobile 0.3.0 → 0.3.2

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
@@ -11,7 +11,7 @@ import { createSocket } from "node:dgram";
11
11
  import { homedir, hostname, networkInterfaces } from "node:os";
12
12
  import { createServer as createServer$1, request } from "node:http";
13
13
  import { createServer as createServer$2 } from "node:https";
14
- import { Transform } from "node:stream";
14
+ import { Transform, finished } from "node:stream";
15
15
  import { pipeline } from "node:stream/promises";
16
16
  import { createGzip, gzip } from "node:zlib";
17
17
  import Bonjour from "bonjour-service";
@@ -1250,14 +1250,33 @@ const EXTENSION_LIMITS = Object.freeze({
1250
1250
  });
1251
1251
  /** A misbehaving host activation must not wedge the local watcher forever. */
1252
1252
  const HOST_ACTIVATION_TIMEOUT_MS = 5e3;
1253
- async function withActivationTimeout(promise, id) {
1253
+ /** The previous Host outlives the hidden-page refresh interval and one timed refresh. */
1254
+ const RETIRED_GENERATION_TTL_MS = 6e5;
1255
+ /** Extension teardown is advisory and must never stop watcher progress. */
1256
+ const HOST_TEARDOWN_TIMEOUT_MS = 2e3;
1257
+ async function withActivationTimeout(promise, id, signal) {
1254
1258
  let timer;
1259
+ let onAbort;
1255
1260
  try {
1256
- return await Promise.race([promise, new Promise((_, reject) => {
1257
- timer = setTimeout(() => reject(new MobileExtensionError("host_load_timeout", `extension ${id} activation timed out`, 500)), HOST_ACTIVATION_TIMEOUT_MS);
1258
- })]);
1261
+ return await Promise.race([
1262
+ promise,
1263
+ new Promise((_, reject) => {
1264
+ timer = setTimeout(() => reject(new MobileExtensionError("host_load_timeout", `extension ${id} activation timed out`, 500)), HOST_ACTIVATION_TIMEOUT_MS);
1265
+ }),
1266
+ new Promise((_, reject) => {
1267
+ const abort = () => {
1268
+ reject(new MobileExtensionError("host_activation_closed", `extension ${id} activation is closed`, 409));
1269
+ };
1270
+ if (signal.aborted) abort();
1271
+ else {
1272
+ onAbort = abort;
1273
+ signal.addEventListener("abort", abort, { once: true });
1274
+ }
1275
+ })
1276
+ ]);
1259
1277
  } finally {
1260
1278
  if (timer !== void 0) clearTimeout(timer);
1279
+ if (onAbort !== void 0) signal.removeEventListener("abort", onAbort);
1261
1280
  }
1262
1281
  }
1263
1282
  /** A controlled business failure returned by an extension action or route. */
@@ -1346,21 +1365,92 @@ async function optionalFile(root, name, maximum, field) {
1346
1365
  }
1347
1366
  async function optionalBytes(root, name, maximum, field) {
1348
1367
  const path = await optionalFile(root, name, maximum, field);
1349
- return path === void 0 ? Buffer.alloc(0) : readFile(path);
1368
+ return path === void 0 ? void 0 : readFile(path);
1369
+ }
1370
+ function assertRealPathWithin(rootReal, targetReal, field) {
1371
+ const relation = relative(rootReal, targetReal);
1372
+ if (relation === "" || relation.startsWith("..") || isAbsolute(relation)) throw new MobileExtensionError("invalid_extension_path", `${field} escapes extension directory`);
1373
+ }
1374
+ async function realExtensionRoot(directory) {
1375
+ const root = resolve(directory);
1376
+ const info = await lstat(root);
1377
+ if (!info.isDirectory() || info.isSymbolicLink()) throw new MobileExtensionError("invalid_extension", "extension directory must be real");
1378
+ return realpath(root);
1379
+ }
1380
+ async function assetSnapshot(extensionRootReal) {
1381
+ const assetsPath = join(extensionRootReal, "assets");
1382
+ let assetsInfo;
1383
+ try {
1384
+ assetsInfo = await lstat(assetsPath);
1385
+ } catch (error) {
1386
+ if (error.code === "ENOENT") return /* @__PURE__ */ new Map();
1387
+ throw error;
1388
+ }
1389
+ if (!assetsInfo.isDirectory() || assetsInfo.isSymbolicLink()) throw new MobileExtensionError("invalid_extension", "assets must be a real directory");
1390
+ const assetsReal = await realpath(assetsPath);
1391
+ assertRealPathWithin(extensionRootReal, assetsReal, "assets");
1392
+ const snapshots = /* @__PURE__ */ new Map();
1393
+ const visit = async (directoryReal, prefix) => {
1394
+ assertRealPathWithin(extensionRootReal, directoryReal, "asset directory");
1395
+ const handle = await opendir(directoryReal);
1396
+ const entries = [];
1397
+ try {
1398
+ for await (const entry of handle) entries.push(entry);
1399
+ } finally {
1400
+ await handle.close().catch(() => void 0);
1401
+ }
1402
+ entries.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
1403
+ for (const entry of entries) {
1404
+ const path = join(directoryReal, entry.name);
1405
+ const info = await lstat(path);
1406
+ if (info.isSymbolicLink()) throw new MobileExtensionError("invalid_extension_path", "asset escapes extension directory");
1407
+ const targetReal = await realpath(path);
1408
+ assertRealPathWithin(extensionRootReal, targetReal, "asset");
1409
+ const key = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
1410
+ if (info.isDirectory()) {
1411
+ await visit(targetReal, key);
1412
+ continue;
1413
+ }
1414
+ if (!info.isFile() || info.size > EXTENSION_LIMITS.asset) throw new MobileExtensionError("invalid_extension", "asset must be a regular file within its size limit");
1415
+ const body = await readFile(targetReal);
1416
+ snapshots.set(key, Object.freeze({
1417
+ body,
1418
+ digest: createHash("sha256").update(body).digest("hex"),
1419
+ name: entry.name
1420
+ }));
1421
+ }
1422
+ };
1423
+ await visit(assetsReal, "");
1424
+ return snapshots;
1350
1425
  }
1351
1426
  async function extensionFingerprint(directory) {
1352
- const manifestFile = await regularFile$1(join(directory, "extension.json"), EXTENSION_LIMITS.manifest, "extension.json");
1427
+ const root = await realExtensionRoot(directory);
1428
+ const manifestFile = await regularFile$1(join(root, "extension.json"), EXTENSION_LIMITS.manifest, "extension.json");
1353
1429
  const manifestBody = await readFile(manifestFile.path);
1354
1430
  const manifest = parseExtensionManifest(JSON.parse(manifestBody.toString("utf8")));
1355
- if (manifest.id !== basename(resolve(directory))) throw new MobileExtensionError("invalid_manifest", "extension id must match its directory name");
1356
- const [host, script, style] = await Promise.all([
1357
- optionalBytes(directory, "host.mjs", EXTENSION_LIMITS.script, "host.mjs"),
1358
- optionalBytes(directory, "mobile.js", EXTENSION_LIMITS.script, "mobile.js"),
1359
- optionalBytes(directory, "mobile.css", EXTENSION_LIMITS.css, "mobile.css")
1431
+ if (manifest.id !== basename(root)) throw new MobileExtensionError("invalid_manifest", "extension id must match its directory name");
1432
+ const [host, script, style, assets] = await Promise.all([
1433
+ optionalBytes(root, "host.mjs", EXTENSION_LIMITS.script, "host.mjs"),
1434
+ optionalBytes(root, "mobile.js", EXTENSION_LIMITS.script, "mobile.js"),
1435
+ optionalBytes(root, "mobile.css", EXTENSION_LIMITS.css, "mobile.css"),
1436
+ assetSnapshot(root)
1360
1437
  ]);
1438
+ const digest = createHash("sha256").update(`manifest:${manifestBody.byteLength}:`).update(createHash("sha256").update(manifestBody).digest());
1439
+ for (const [name, body] of [
1440
+ ["host", host],
1441
+ ["script", script],
1442
+ ["style", style]
1443
+ ]) {
1444
+ digest.update(`\0${name}:${body?.byteLength ?? -1}:`);
1445
+ if (body !== void 0) digest.update(createHash("sha256").update(body).digest());
1446
+ }
1447
+ for (const [name, asset] of assets) digest.update(`\0asset:${Buffer.byteLength(name)}:${name}:${asset.body.byteLength}:${asset.digest}`);
1361
1448
  return {
1362
1449
  manifest,
1363
- digest: createHash("sha256").update(manifestBody).update(host).update(script).update(style).digest("hex")
1450
+ digest: digest.digest("hex"),
1451
+ assets,
1452
+ ...script === void 0 ? {} : { scriptBody: script },
1453
+ ...style === void 0 ? {} : { styleBody: style }
1364
1454
  };
1365
1455
  }
1366
1456
  function routeKey(route) {
@@ -1415,20 +1505,49 @@ function validateDefinition(definition) {
1415
1505
  ...routes.length === 0 ? {} : { routes: Object.freeze(routes) }
1416
1506
  });
1417
1507
  }
1418
- function combineSignals(first, second) {
1419
- if (first.aborted || second.aborted) return AbortSignal.abort(first.aborted ? first.reason : second.reason);
1420
- return AbortSignal.any([first, second]);
1508
+ function combineSignalLifetime(first, second) {
1509
+ if (first.aborted || second.aborted) {
1510
+ const aborted = new AbortController();
1511
+ aborted.abort(first.aborted ? first.reason : second.reason);
1512
+ return {
1513
+ signal: aborted.signal,
1514
+ cleanup: () => void 0
1515
+ };
1516
+ }
1517
+ const controller = new AbortController();
1518
+ const cleanup = () => {
1519
+ first.removeEventListener("abort", abortFirst);
1520
+ second.removeEventListener("abort", abortSecond);
1521
+ };
1522
+ const abortFirst = () => {
1523
+ cleanup();
1524
+ controller.abort(first.reason);
1525
+ };
1526
+ const abortSecond = () => {
1527
+ cleanup();
1528
+ controller.abort(second.reason);
1529
+ };
1530
+ first.addEventListener("abort", abortFirst, { once: true });
1531
+ second.addEventListener("abort", abortSecond, { once: true });
1532
+ return {
1533
+ signal: controller.signal,
1534
+ cleanup
1535
+ };
1421
1536
  }
1422
1537
  /** Host registry and service consumed by both npm plugins and local extensions. */
1423
1538
  var MobileAccessService = class extends Service {
1424
1539
  registered = /* @__PURE__ */ new Map();
1425
1540
  local = /* @__PURE__ */ new Map();
1541
+ retired = /* @__PURE__ */ new Map();
1426
1542
  failures = /* @__PURE__ */ new Map();
1427
- contentHash = "";
1543
+ contentListeners = /* @__PURE__ */ new Set();
1544
+ contentHash = createHash("sha256").update("").digest("hex");
1428
1545
  localRoot;
1429
1546
  localContext;
1430
1547
  localTimer;
1431
1548
  localRefreshing;
1549
+ localRefreshAbort;
1550
+ localLifecycle = 0;
1432
1551
  localClosed = true;
1433
1552
  constructor(ctx) {
1434
1553
  super(ctx, "mobileAccess");
@@ -1454,9 +1573,21 @@ var MobileAccessService = class extends Service {
1454
1573
  contentDigest() {
1455
1574
  return this.contentHash;
1456
1575
  }
1576
+ /** Subscribe to committed extension generation changes. */
1577
+ onContentChanged(listener) {
1578
+ this.contentListeners.add(listener);
1579
+ return () => {
1580
+ this.contentListeners.delete(listener);
1581
+ };
1582
+ }
1457
1583
  updateContentHash() {
1458
1584
  const parts = [...[...this.registered.values()].map((entry) => entry.definition.id), ...[...this.local.values()].map((active) => `${active.manifest.id}:${active.digest}`)];
1459
- this.contentHash = createHash("sha256").update(parts.sort().join("|")).digest("hex");
1585
+ const next = createHash("sha256").update(parts.sort().join("|")).digest("hex");
1586
+ if (next === this.contentHash) return;
1587
+ this.contentHash = next;
1588
+ for (const listener of this.contentListeners) try {
1589
+ listener();
1590
+ } catch {}
1460
1591
  }
1461
1592
  /** Return the current client-facing manifest, deterministically sorted by id. */
1462
1593
  manifest() {
@@ -1470,8 +1601,9 @@ var MobileAccessService = class extends Service {
1470
1601
  });
1471
1602
  for (const active of this.local.values()) entries.set(active.manifest.id, {
1472
1603
  ...active.manifest,
1473
- ...active.scriptFile === void 0 ? {} : { scriptUrl: `/mobile-access/extensions/${active.manifest.id}/mobile.js` },
1474
- ...active.styleFile === void 0 ? {} : { styleUrl: `/mobile-access/extensions/${active.manifest.id}/mobile.css` },
1604
+ generation: active.digest,
1605
+ ...active.scriptBody === void 0 ? {} : { scriptUrl: `/mobile-access/extensions/${active.manifest.id}/mobile.js?generation=${active.digest}` },
1606
+ ...active.styleBody === void 0 ? {} : { styleUrl: `/mobile-access/extensions/${active.manifest.id}/mobile.css?generation=${active.digest}` },
1475
1607
  assetsUrl: `/mobile-access/extensions/${active.manifest.id}/assets/`
1476
1608
  });
1477
1609
  return [...entries.values()].sort((left, right) => left.id.localeCompare(right.id));
@@ -1484,49 +1616,52 @@ var MobileAccessService = class extends Service {
1484
1616
  });
1485
1617
  }
1486
1618
  /** Locate one active extension. */
1487
- extension(id) {
1619
+ extension(id, generation) {
1620
+ if (generation !== void 0) {
1621
+ const current = this.local.get(id);
1622
+ if (current?.digest === generation) return current;
1623
+ const previous = this.retired.get(id)?.active;
1624
+ return previous?.digest === generation ? previous : void 0;
1625
+ }
1488
1626
  return this.local.get(id) ?? this.registered.get(id)?.definition;
1489
1627
  }
1490
1628
  /** Return the active local generation signal for gateway cancellation wiring. */
1491
- signal(id) {
1492
- return this.local.get(id)?.controller.signal;
1629
+ signal(id, generation) {
1630
+ const extension = this.extension(id, generation);
1631
+ return extension !== void 0 && "host" in extension ? extension.controller.signal : void 0;
1493
1632
  }
1494
1633
  /** Read a local client entry after validating that it remains inside its directory. */
1495
- async readClientFile(id, kind, signal) {
1634
+ async readClientFile(id, kind, signal, generation) {
1496
1635
  signal?.throwIfAborted();
1497
- const active = this.local.get(id);
1498
- if (active === void 0) throw new MobileExtensionError("extension_not_found", "extension not found", 404);
1499
- const path = kind === "script" ? active.scriptFile : active.styleFile;
1500
- if (path === void 0) throw new MobileExtensionError("extension_asset_not_found", "extension asset not found", 404);
1501
- const file = await regularFile$1(path, kind === "script" ? EXTENSION_LIMITS.script : EXTENSION_LIMITS.css, kind);
1502
- const body = await readFile(file.path, { signal });
1636
+ const selected = this.extension(id, generation);
1637
+ const active = selected !== void 0 && "host" in selected ? selected : void 0;
1638
+ if (active === void 0) throw new MobileExtensionError("extension_generation_not_found", "extension generation not found", 404);
1639
+ const snapshot = kind === "script" ? active.scriptBody : active.styleBody;
1640
+ if (snapshot === void 0) throw new MobileExtensionError("extension_asset_not_found", "extension asset not found", 404);
1641
+ const body = Buffer.from(snapshot);
1503
1642
  return {
1504
1643
  body,
1505
1644
  digest: createHash("sha256").update(body).digest("hex")
1506
1645
  };
1507
1646
  }
1508
- /** Read a local static asset after containment and size checks. */
1509
- async readAsset(id, assetPath, signal) {
1647
+ /** Read a generation-pinned static asset from its validated snapshot. */
1648
+ async readAsset(id, assetPath, signal, generation) {
1510
1649
  signal?.throwIfAborted();
1511
- const active = this.local.get(id);
1512
- if (active === void 0) throw new MobileExtensionError("extension_not_found", "extension not found", 404);
1513
- let file;
1514
- try {
1515
- file = await containedPath(join(active.directory, "assets"), assetPath, EXTENSION_LIMITS.asset, "asset");
1516
- } catch (error) {
1517
- if (error.code === "ENOENT") throw new MobileExtensionError("extension_asset_not_found", "extension asset not found", 404);
1518
- throw error;
1519
- }
1520
- const body = await readFile(file.path, { signal });
1650
+ const selected = this.extension(id, generation);
1651
+ const active = selected !== void 0 && "host" in selected ? selected : void 0;
1652
+ if (active === void 0) throw new MobileExtensionError("extension_generation_not_found", "extension generation not found", 404);
1653
+ const normalized = normalizeRelativePath(assetPath, "asset");
1654
+ const asset = active.assets.get(normalized);
1655
+ if (asset === void 0) throw new MobileExtensionError("extension_asset_not_found", "extension asset not found", 404);
1521
1656
  return {
1522
- body,
1523
- digest: createHash("sha256").update(body).digest("hex"),
1524
- name: basename(file.path)
1657
+ body: Buffer.from(asset.body),
1658
+ digest: asset.digest,
1659
+ name: asset.name
1525
1660
  };
1526
1661
  }
1527
1662
  /** Invoke one action after parsing its input and binding the request lifetime. */
1528
- async invoke(id, actionName, input, context) {
1529
- const extension = this.extension(id);
1663
+ async invoke(id, actionName, input, context, generation) {
1664
+ const extension = this.extension(id, generation);
1530
1665
  if (extension === void 0) throw new MobileExtensionError("extension_not_found", "extension not found", 404);
1531
1666
  const action = ("host" in extension ? extension.host : extension).actions?.[actionName];
1532
1667
  if (action === void 0) throw new MobileExtensionError("action_not_found", "action not found", 404);
@@ -1536,7 +1671,8 @@ var MobileAccessService = class extends Service {
1536
1671
  } catch {
1537
1672
  throw new MobileExtensionError("invalid_action_input", "action input is invalid", 400);
1538
1673
  }
1539
- const signal = "host" in extension ? combineSignals(extension.controller.signal, context.signal) : context.signal;
1674
+ const lifetime = "host" in extension ? combineSignalLifetime(extension.controller.signal, context.signal) : void 0;
1675
+ const signal = lifetime?.signal ?? context.signal;
1540
1676
  try {
1541
1677
  return await action.run({
1542
1678
  ...context,
@@ -1545,39 +1681,53 @@ var MobileAccessService = class extends Service {
1545
1681
  } catch (error) {
1546
1682
  if (error instanceof MobileExtensionError) throw error;
1547
1683
  throw new MobileExtensionError("extension_failed", "extension action failed", 500);
1684
+ } finally {
1685
+ lifetime?.cleanup();
1548
1686
  }
1549
1687
  }
1550
1688
  /** Match one route and invoke it with a generation-bound abort signal. */
1551
- async route(id, method, pathname, request) {
1552
- const extension = this.extension(id);
1689
+ async route(id, method, pathname, request, generation) {
1690
+ const extension = this.extension(id, generation);
1553
1691
  if (extension === void 0) throw new MobileExtensionError("extension_not_found", "extension not found", 404);
1554
1692
  const route = ("host" in extension ? extension.host : extension).routes?.find((candidate) => {
1555
1693
  if (candidate.method !== method) return false;
1556
1694
  return (candidate.kind ?? "exact") === "exact" ? candidate.path === pathname : pathname === candidate.path || pathname.startsWith(`${candidate.path}/`);
1557
1695
  });
1558
1696
  if (route === void 0) throw new MobileExtensionError("route_not_found", "route not found", 404);
1697
+ const lifetime = "host" in extension ? combineSignalLifetime(extension.controller.signal, request.signal) : void 0;
1698
+ let releaseLifetime = true;
1559
1699
  try {
1560
- const routeRequest = "host" in extension ? {
1700
+ const routeRequest = lifetime === void 0 ? request : {
1561
1701
  ...request,
1562
- signal: combineSignals(extension.controller.signal, request.signal)
1563
- } : request;
1702
+ signal: lifetime.signal
1703
+ };
1564
1704
  const result = await route.handle(routeRequest);
1565
1705
  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);
1706
+ if (lifetime !== void 0 && isReadable(result.body)) {
1707
+ releaseLifetime = false;
1708
+ releaseSignalLifetimeWhenStreamSettles(result.body, lifetime.cleanup);
1709
+ }
1566
1710
  return result;
1567
1711
  } catch (error) {
1568
1712
  if (error instanceof MobileExtensionError) throw error;
1569
1713
  throw new MobileExtensionError("extension_failed", "extension route failed", 500);
1714
+ } finally {
1715
+ if (releaseLifetime) lifetime?.cleanup();
1570
1716
  }
1571
1717
  }
1572
1718
  /** Start the local directory watcher; an absent directory is intentionally inert. */
1573
1719
  async startLocal(root, context) {
1574
- if (this.localRoot !== void 0 && resolve(this.localRoot) !== resolve(root)) await this.stopLocal();
1720
+ const targetRoot = resolve(root);
1721
+ if (this.localRoot !== void 0 && resolve(this.localRoot) !== targetRoot) await this.stopLocal();
1575
1722
  if (this.localTimer !== void 0) clearInterval(this.localTimer);
1576
- this.localRoot = resolve(root);
1723
+ const lifecycle = ++this.localLifecycle;
1724
+ this.localRoot = targetRoot;
1577
1725
  this.localContext = context;
1578
1726
  this.localClosed = false;
1579
1727
  await mkdir(this.localRoot, { recursive: true });
1728
+ if (this.localClosed || this.localLifecycle !== lifecycle || this.localRoot !== targetRoot || this.localContext !== context) return;
1580
1729
  await this.refreshLocal();
1730
+ if (this.localClosed || this.localLifecycle !== lifecycle || this.localRoot !== targetRoot || this.localContext !== context) return;
1581
1731
  this.localTimer = setInterval(() => {
1582
1732
  this.refreshLocal();
1583
1733
  }, 2e3);
@@ -1586,23 +1736,43 @@ var MobileAccessService = class extends Service {
1586
1736
  /** Stop the watcher and abort every local host generation. */
1587
1737
  async stopLocal() {
1588
1738
  this.localClosed = true;
1739
+ const lifecycle = ++this.localLifecycle;
1589
1740
  if (this.localTimer !== void 0) clearInterval(this.localTimer);
1590
1741
  this.localTimer = void 0;
1591
- const previous = [...this.local.values()];
1742
+ const refreshing = this.localRefreshing;
1743
+ this.localRefreshAbort?.abort();
1744
+ const previous = [...this.local.values(), ...[...this.retired.values()].map((entry) => entry.active)];
1745
+ this.local.clear();
1746
+ for (const entry of this.retired.values()) clearTimeout(entry.timer);
1747
+ this.retired.clear();
1748
+ this.failures.clear();
1749
+ this.updateContentHash();
1750
+ await Promise.allSettled([abortAndDisposeLocal(previous), ...refreshing === void 0 ? [] : [refreshing]]);
1751
+ if (this.localLifecycle !== lifecycle) return;
1752
+ const late = [...this.local.values(), ...[...this.retired.values()].map((entry) => entry.active)];
1592
1753
  this.local.clear();
1754
+ for (const entry of this.retired.values()) clearTimeout(entry.timer);
1755
+ this.retired.clear();
1593
1756
  this.failures.clear();
1594
- await disposeLocal(previous);
1757
+ this.updateContentHash();
1758
+ await abortAndDisposeLocal(late);
1759
+ if (this.localTimer !== void 0) clearInterval(this.localTimer);
1760
+ this.localTimer = void 0;
1595
1761
  }
1596
1762
  /** Refresh all local extensions atomically; failures keep the previous snapshot. */
1597
1763
  refreshLocal() {
1598
1764
  if (this.localRefreshing !== void 0) return this.localRefreshing;
1599
- this.localRefreshing = this.stageAndCommit().finally(() => {
1600
- this.localRefreshing = void 0;
1765
+ const controller = new AbortController();
1766
+ this.localRefreshAbort = controller;
1767
+ const refreshing = this.stageAndCommit(controller.signal).finally(() => {
1768
+ if (this.localRefreshing === refreshing) this.localRefreshing = void 0;
1769
+ if (this.localRefreshAbort === controller) this.localRefreshAbort = void 0;
1601
1770
  });
1602
- return this.localRefreshing;
1771
+ this.localRefreshing = refreshing;
1772
+ return refreshing;
1603
1773
  }
1604
- async stageAndCommit() {
1605
- if (this.localClosed || this.localRoot === void 0 || this.localContext === void 0) return;
1774
+ async stageAndCommit(signal) {
1775
+ if (this.localClosed || signal.aborted || this.localRoot === void 0 || this.localContext === void 0) return;
1606
1776
  let names = [];
1607
1777
  try {
1608
1778
  const directory = await opendir(this.localRoot);
@@ -1620,19 +1790,29 @@ var MobileAccessService = class extends Service {
1620
1790
  let failingName = "local";
1621
1791
  try {
1622
1792
  for (const name of names) {
1793
+ signal.throwIfAborted();
1623
1794
  failingName = name;
1624
1795
  const directory = join(this.localRoot, name);
1625
1796
  const fingerprint = await extensionFingerprint(directory);
1626
- const previous = this.local.get(fingerprint.manifest.id);
1627
- if (previous?.directory === resolve(directory) && previous.digest === fingerprint.digest) staged.push(previous);
1797
+ const current = this.local.get(fingerprint.manifest.id);
1798
+ const retired = this.retired.get(fingerprint.manifest.id)?.active;
1799
+ const previous = current?.digest === fingerprint.digest ? current : retired?.digest === fingerprint.digest ? retired : void 0;
1800
+ if (previous?.digest === fingerprint.digest) staged.push(previous);
1628
1801
  else {
1629
- const fresh = await loadLocalExtension(directory, this.localContext, fingerprint);
1802
+ const fresh = await loadLocalExtension(directory, this.localContext, fingerprint, signal);
1803
+ try {
1804
+ signal.throwIfAborted();
1805
+ if ((await extensionFingerprint(directory)).digest !== fingerprint.digest) throw new MobileExtensionError("extension_changed_during_activation", `extension ${fingerprint.manifest.id} changed during activation`, 409);
1806
+ } catch (error) {
1807
+ await abortAndDisposeLocal([fresh]);
1808
+ throw error;
1809
+ }
1630
1810
  staged.push(fresh);
1631
1811
  stagedFresh.push(fresh);
1632
1812
  }
1633
1813
  }
1634
- if (this.localClosed || this.localRoot === void 0 || this.localContext === void 0) {
1635
- await disposeLocal(stagedFresh);
1814
+ if (this.localClosed || signal.aborted || this.localRoot === void 0 || this.localContext === void 0) {
1815
+ await abortAndDisposeLocal(stagedFresh);
1636
1816
  return;
1637
1817
  }
1638
1818
  const duplicate = /* @__PURE__ */ new Set();
@@ -1641,42 +1821,106 @@ var MobileAccessService = class extends Service {
1641
1821
  duplicate.add(entry.manifest.id);
1642
1822
  }
1643
1823
  const previous = [...this.local.values()];
1824
+ for (const entry of staged) {
1825
+ const retired = this.retired.get(entry.manifest.id);
1826
+ if (retired?.active === entry) {
1827
+ clearTimeout(retired.timer);
1828
+ this.retired.delete(entry.manifest.id);
1829
+ }
1830
+ }
1831
+ const stagedIds = new Set(staged.map((entry) => entry.manifest.id));
1832
+ const removed = [];
1833
+ for (const entry of previous) {
1834
+ if (staged.includes(entry)) continue;
1835
+ if (stagedIds.has(entry.manifest.id)) {
1836
+ this.retire(entry);
1837
+ continue;
1838
+ }
1839
+ removed.push(entry);
1840
+ const retired = this.retired.get(entry.manifest.id);
1841
+ if (retired !== void 0) {
1842
+ clearTimeout(retired.timer);
1843
+ this.retired.delete(entry.manifest.id);
1844
+ removed.push(retired.active);
1845
+ }
1846
+ }
1644
1847
  this.local.clear();
1645
1848
  for (const entry of staged) this.local.set(entry.manifest.id, entry);
1646
1849
  for (const entry of staged) this.failures.delete(entry.manifest.id);
1647
1850
  for (const name of names) this.failures.delete(name);
1648
1851
  for (const failure of this.failures.keys()) if (failure !== "local" && !names.includes(failure)) this.failures.delete(failure);
1649
1852
  this.failures.delete("local");
1650
- await disposeLocal(previous.filter((entry) => !staged.includes(entry)));
1853
+ if (removed.length > 0) abortAndDisposeLocal(removed);
1651
1854
  this.updateContentHash();
1652
1855
  } catch (error) {
1653
- await disposeLocal(stagedFresh);
1856
+ await abortAndDisposeLocal(stagedFresh);
1857
+ if (this.localClosed || signal.aborted) return;
1654
1858
  const message = error instanceof Error ? error.message : String(error);
1655
1859
  this.failures.set(failingName, message);
1656
1860
  if (!(error instanceof MobileExtensionError)) this.ctx.logger.warn(error instanceof Error ? error : new Error(String(error)));
1657
1861
  }
1658
1862
  }
1863
+ retire(active) {
1864
+ const previous = this.retired.get(active.manifest.id);
1865
+ if (previous?.active === active) return;
1866
+ if (previous !== void 0) {
1867
+ clearTimeout(previous.timer);
1868
+ this.retired.delete(active.manifest.id);
1869
+ abortAndDisposeLocal([previous.active]);
1870
+ }
1871
+ const timer = setTimeout(() => {
1872
+ if (this.retired.get(active.manifest.id)?.active !== active) return;
1873
+ this.retired.delete(active.manifest.id);
1874
+ abortAndDisposeLocal([active]);
1875
+ }, RETIRED_GENERATION_TTL_MS);
1876
+ timer.unref();
1877
+ this.retired.set(active.manifest.id, {
1878
+ active,
1879
+ timer
1880
+ });
1881
+ }
1659
1882
  };
1660
1883
  function isReadable(value) {
1661
1884
  return value !== null && typeof value === "object" && typeof value.pipe === "function";
1662
1885
  }
1663
- async function disposeLocal(entries) {
1886
+ function releaseSignalLifetimeWhenStreamSettles(stream, cleanup) {
1887
+ let stopObserving;
1888
+ stopObserving = finished(stream, () => {
1889
+ stopObserving?.();
1890
+ cleanup();
1891
+ });
1892
+ }
1893
+ function invokeCleanups(cleanups) {
1894
+ const pending = [];
1895
+ for (const cleanup of [...cleanups].reverse()) try {
1896
+ pending.push(Promise.resolve(cleanup()));
1897
+ } catch {}
1898
+ return pending;
1899
+ }
1900
+ async function settleBounded(pending, timeoutMs) {
1901
+ if (pending.length === 0) return;
1902
+ let timer;
1903
+ await Promise.race([Promise.allSettled(pending), new Promise((resolveTimeout) => {
1904
+ timer = setTimeout(resolveTimeout, timeoutMs);
1905
+ })]);
1906
+ if (timer !== void 0) clearTimeout(timer);
1907
+ }
1908
+ async function abortAndDisposeLocal(entries) {
1909
+ const pending = [];
1664
1910
  for (const entry of entries) {
1665
1911
  entry.controller.abort();
1666
- for (const cleanup of [...entry.cleanups].reverse()) try {
1667
- await cleanup();
1668
- } catch {}
1912
+ pending.push(...invokeCleanups(entry.cleanups));
1669
1913
  }
1914
+ await settleBounded(pending, HOST_TEARDOWN_TIMEOUT_MS);
1670
1915
  }
1671
- async function loadLocalExtension(directory, context, known) {
1672
- const root = resolve(directory);
1673
- const rootStat = await lstat(root);
1674
- if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new MobileExtensionError("invalid_extension", "extension directory must be real");
1916
+ async function loadLocalExtension(directory, context, known, parentSignal) {
1917
+ const root = await realExtensionRoot(directory);
1675
1918
  const manifestFile = await regularFile$1(join(root, "extension.json"), EXTENSION_LIMITS.manifest, "extension.json");
1676
1919
  const manifest = known?.manifest ?? parseExtensionManifest(JSON.parse(await readFile(manifestFile.path, "utf8")));
1677
1920
  if (manifest.id !== basename(root)) throw new MobileExtensionError("invalid_manifest", "extension id must match its directory name");
1678
- const scriptFile = await optionalFile(root, "mobile.js", EXTENSION_LIMITS.script, "mobile.js");
1679
- const styleFile = await optionalFile(root, "mobile.css", EXTENSION_LIMITS.css, "mobile.css");
1921
+ const scriptBody = known === void 0 ? await optionalFile(root, "mobile.js", EXTENSION_LIMITS.script, "mobile.js").then((path) => path === void 0 ? void 0 : readFile(path)) : known.scriptBody;
1922
+ const styleBody = known === void 0 ? await optionalFile(root, "mobile.css", EXTENSION_LIMITS.css, "mobile.css").then((path) => path === void 0 ? void 0 : readFile(path)) : known.styleBody;
1923
+ const assets = known?.assets ?? await assetSnapshot(root);
1680
1924
  const hostFile = await optionalFile(root, "host.mjs", EXTENSION_LIMITS.script, "host.mjs");
1681
1925
  const controller = new AbortController();
1682
1926
  const actions = {};
@@ -1684,43 +1928,60 @@ async function loadLocalExtension(directory, context, known) {
1684
1928
  const cleanups = [];
1685
1929
  const pendingEffects = [];
1686
1930
  let activationOpen = true;
1931
+ const onParentAbort = () => {
1932
+ controller.abort(parentSignal?.reason);
1933
+ };
1934
+ if (parentSignal?.aborted === true) onParentAbort();
1935
+ else parentSignal?.addEventListener("abort", onParentAbort, { once: true });
1936
+ const ensureActivationOpen = () => {
1937
+ if (!activationOpen || controller.signal.aborted) throw new MobileExtensionError("host_activation_closed", `extension ${manifest.id} activation is closed`, 409);
1938
+ };
1687
1939
  const api = {
1688
1940
  manifest,
1689
1941
  context,
1690
1942
  schema: z,
1691
1943
  signal: controller.signal,
1692
1944
  action(name, spec) {
1945
+ ensureActivationOpen();
1693
1946
  if (actions[name] !== void 0) throw new MobileExtensionError("duplicate_action", `duplicate action ${name}`);
1694
1947
  actions[name] = spec;
1695
1948
  },
1696
1949
  route(spec) {
1950
+ ensureActivationOpen();
1697
1951
  routes.push(spec);
1698
1952
  },
1699
1953
  effect(setup) {
1954
+ ensureActivationOpen();
1700
1955
  const result = setup();
1701
1956
  if (result instanceof Promise) pendingEffects.push(result.then(async (cleanup) => {
1702
1957
  if (typeof cleanup !== "function") return;
1703
1958
  if (activationOpen) cleanups.push(cleanup);
1704
1959
  else await cleanup();
1705
1960
  }));
1706
- else if (typeof result === "function") cleanups.push(result);
1961
+ else if (typeof result === "function") {
1962
+ if (activationOpen) cleanups.push(result);
1963
+ else Promise.resolve(result()).catch(() => void 0);
1964
+ }
1707
1965
  }
1708
1966
  };
1709
1967
  try {
1710
1968
  const activate = async () => {
1969
+ controller.signal.throwIfAborted();
1711
1970
  if (hostFile !== void 0) {
1712
1971
  const digest = createHash("sha256").update(await readFile(hostFile)).digest("hex");
1972
+ controller.signal.throwIfAborted();
1713
1973
  let imported;
1714
1974
  try {
1715
1975
  imported = await import(`${pathToFileURL(hostFile).href}?dsh_generation=${digest}`);
1716
1976
  } catch {
1717
1977
  throw new MobileExtensionError("host_load_failed", `could not load ${manifest.id}/host.mjs`, 500);
1718
1978
  }
1979
+ controller.signal.throwIfAborted();
1719
1980
  if (imported.default !== void 0) await imported.default(api);
1720
1981
  }
1721
1982
  await Promise.all(pendingEffects);
1722
1983
  };
1723
- await withActivationTimeout(activate(), manifest.id);
1984
+ await withActivationTimeout(activate(), manifest.id, controller.signal);
1724
1985
  const host = validateDefinition({
1725
1986
  ...manifest,
1726
1987
  actions,
@@ -1731,8 +1992,9 @@ async function loadLocalExtension(directory, context, known) {
1731
1992
  return Object.freeze({
1732
1993
  manifest,
1733
1994
  directory: root,
1734
- ...scriptFile === void 0 ? {} : { scriptFile },
1735
- ...styleFile === void 0 ? {} : { styleFile },
1995
+ ...scriptBody === void 0 ? {} : { scriptBody },
1996
+ ...styleBody === void 0 ? {} : { styleBody },
1997
+ assets,
1736
1998
  host,
1737
1999
  controller,
1738
2000
  cleanups: Object.freeze(cleanups),
@@ -1740,12 +2002,12 @@ async function loadLocalExtension(directory, context, known) {
1740
2002
  });
1741
2003
  } catch (error) {
1742
2004
  activationOpen = false;
1743
- await withActivationTimeout(Promise.allSettled(pendingEffects), manifest.id).catch(() => void 0);
1744
2005
  controller.abort();
1745
- for (const cleanup of [...cleanups].reverse()) try {
1746
- await cleanup();
1747
- } catch {}
2006
+ const cleanupPromises = invokeCleanups(cleanups.splice(0));
2007
+ await settleBounded([...pendingEffects, ...cleanupPromises], HOST_TEARDOWN_TIMEOUT_MS);
1748
2008
  throw error;
2009
+ } finally {
2010
+ parentSignal?.removeEventListener("abort", onParentAbort);
1749
2011
  }
1750
2012
  }
1751
2013
  /** Construct the service in a Cordis plugin without importing DSH internals. */
@@ -1772,6 +2034,8 @@ const UPSTREAM_AUTH_REFRESH_MARGIN_MS = 6e4;
1772
2034
  const UPSTREAM_COOKIE_PAIR = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+=[\x21-\x3A\x3C-\x7E]*$/u;
1773
2035
  const CUSTOM_STYLE_FALLBACK = "/* Add mobile overrides in the DSH home mobile-access/mobile.css file. */\n";
1774
2036
  const CUSTOM_SCRIPT_FALLBACK = "window.dshMobile?.register(() => undefined)\n";
2037
+ const EXTENSION_CHANGE_POLL_MS = 2e3;
2038
+ const EXTENSION_EVENT_HEARTBEAT_MS = 15e3;
1775
2039
  const MOBILE_CLIENT_MODULE = "dsh-mobile";
1776
2040
  const CONNECTION_MODULE = "@deepseek-ai/dsh-client-connection";
1777
2041
  const RUNTIME_MODULE = "@deepseek-ai/dsh-client-runtime";
@@ -2247,6 +2511,7 @@ function discoveryBroadcastTargets(cidrs) {
2247
2511
  function extensionTarget(pathname) {
2248
2512
  const prefix = `${AUTH_PREFIX}/extensions`;
2249
2513
  if (pathname === prefix || pathname === `${prefix}/` || pathname === `${prefix}/manifest`) return { kind: "manifest" };
2514
+ if (pathname === `${prefix}/events`) return { kind: "events" };
2250
2515
  if (!pathname.startsWith(`${prefix}/`)) return void 0;
2251
2516
  const parts = pathname.slice(prefix.length + 1).split("/");
2252
2517
  const id = parts.shift();
@@ -2276,12 +2541,21 @@ function extensionTarget(pathname) {
2276
2541
  path: `/${parts.join("/")}`.replace(/\/{2,}/gu, "/")
2277
2542
  };
2278
2543
  }
2544
+ const EXTENSION_GENERATION_HEADER = "x-dsh-mobile-extension-generation";
2545
+ function extensionGeneration(value) {
2546
+ if (value === void 0) return void 0;
2547
+ if (!/^[a-f\d]{64}$/u.test(value)) throw new HttpError(400, "invalid_extension_generation");
2548
+ return value;
2549
+ }
2279
2550
  function mobileBootBatchKey(pathname) {
2280
2551
  return new RegExp(`^${MOBILE_BOOT_BATCH_PREFIX.replaceAll("/", "\\/")}([a-f\\d]{64})\\.js$`, "u").exec(pathname)?.[1];
2281
2552
  }
2282
- async function readBoundedBody(request, maximum) {
2553
+ function assertBoundedContentLength(request, maximum) {
2283
2554
  const declared = request.headers["content-length"];
2284
2555
  if (declared !== void 0 && (!/^\d+$/u.test(declared) || Number(declared) > maximum)) throw new HttpError(413, "payload_too_large");
2556
+ }
2557
+ async function readBoundedBody(request, maximum) {
2558
+ assertBoundedContentLength(request, maximum);
2285
2559
  const chunks = [];
2286
2560
  let total = 0;
2287
2561
  for await (const chunk of request) {
@@ -2343,6 +2617,11 @@ var MobileAccessGateway = class {
2343
2617
  activeRequests = /* @__PURE__ */ new Map();
2344
2618
  activeWebSockets = /* @__PURE__ */ new Map();
2345
2619
  mobileBootBatches = /* @__PURE__ */ new Map();
2620
+ extensionEventListeners = /* @__PURE__ */ new Set();
2621
+ extensionEventRevision = 0;
2622
+ extensionChangeTimer;
2623
+ extensionChangeTask;
2624
+ legacyCustomDigest = "";
2346
2625
  upstreamCookie;
2347
2626
  upstreamCookieExpiresAt = 0;
2348
2627
  upstreamCookieTask;
@@ -2352,6 +2631,7 @@ var MobileAccessGateway = class {
2352
2631
  started = false;
2353
2632
  closeTask;
2354
2633
  removeSessionListener;
2634
+ removeExtensionContentListener;
2355
2635
  renewLimiter;
2356
2636
  constructor(config, store, extensions, upstreamAuthenticatedUrl) {
2357
2637
  this.config = config;
@@ -2373,6 +2653,9 @@ var MobileAccessGateway = class {
2373
2653
  this.removeSessionListener = this.access.onSessionEnded((authorization) => {
2374
2654
  this.abortSessionResources(authorization.sessionKey);
2375
2655
  });
2656
+ this.removeExtensionContentListener = this.extensions?.onContentChanged(() => {
2657
+ this.broadcastExtensionChange();
2658
+ }) ?? (() => void 0);
2376
2659
  }
2377
2660
  /** Initialize durable state, validate TLS, and bind the externally reachable listener. */
2378
2661
  async start() {
@@ -2440,6 +2723,11 @@ var MobileAccessGateway = class {
2440
2723
  this.listenerPort = address.port;
2441
2724
  this.policy = new RequestTrustPolicy(this.config.authorities, address.port, this.config.allowedCidrs, this.tlsEnabled);
2442
2725
  if (this.config.discovery) await this.startDiscovery(address.port);
2726
+ await this.pollLegacyCustomChanges();
2727
+ this.extensionChangeTimer = setInterval(() => {
2728
+ this.pollLegacyCustomChanges();
2729
+ }, EXTENSION_CHANGE_POLL_MS);
2730
+ this.extensionChangeTimer.unref();
2443
2731
  } catch (error) {
2444
2732
  await this.closeFailedStart();
2445
2733
  throw error;
@@ -2499,6 +2787,9 @@ var MobileAccessGateway = class {
2499
2787
  }), "utf8");
2500
2788
  }
2501
2789
  async closeFailedStart() {
2790
+ if (this.extensionChangeTimer !== void 0) clearInterval(this.extensionChangeTimer);
2791
+ this.extensionChangeTimer = void 0;
2792
+ this.removeExtensionContentListener();
2502
2793
  if (this.discoveryTimer !== void 0) clearInterval(this.discoveryTimer);
2503
2794
  this.discoveryTimer = void 0;
2504
2795
  await this.closeBonjour();
@@ -2859,6 +3150,11 @@ var MobileAccessGateway = class {
2859
3150
  async handleExtensionRequest(targetInfo, target, request, response, authorization) {
2860
3151
  const extensions = this.extensions;
2861
3152
  if (extensions === void 0) throw new HttpError(404, "not_found");
3153
+ if (targetInfo.kind === "events") {
3154
+ if (request.method !== "GET" || target.search !== "") throw new HttpError(request.method === "GET" ? 400 : 405, request.method === "GET" ? "bad_request" : "method_not_allowed");
3155
+ this.openExtensionEventStream(request, response, authorization);
3156
+ return;
3157
+ }
2862
3158
  if (targetInfo.kind === "manifest") {
2863
3159
  if (request.method !== "GET" && request.method !== "HEAD") throw new HttpError(405, "method_not_allowed");
2864
3160
  const operation = this.allocateRequest(authorization, response, {});
@@ -2906,9 +3202,10 @@ var MobileAccessGateway = class {
2906
3202
  }
2907
3203
  if (targetInfo.kind === "script" || targetInfo.kind === "style" || targetInfo.kind === "asset") {
2908
3204
  if (request.method !== "GET" && request.method !== "HEAD") throw new HttpError(405, "method_not_allowed");
3205
+ const generation = extensionGeneration(new URLSearchParams(target.search).get("generation") ?? void 0);
2909
3206
  const operation = this.allocateRequest(authorization, response, {});
2910
3207
  try {
2911
- const file = targetInfo.kind === "script" ? await extensions.readClientFile(targetInfo.id, "script", operation.signal) : targetInfo.kind === "style" ? await extensions.readClientFile(targetInfo.id, "style", operation.signal) : await extensions.readAsset(targetInfo.id, targetInfo.path ?? "", operation.signal);
3208
+ const file = targetInfo.kind === "script" ? await extensions.readClientFile(targetInfo.id, "script", operation.signal, generation) : targetInfo.kind === "style" ? await extensions.readClientFile(targetInfo.id, "style", operation.signal, generation) : await extensions.readAsset(targetInfo.id, targetInfo.path ?? "", operation.signal, generation);
2912
3209
  if (headerValue(request.headers, "if-none-match") === file.digest) {
2913
3210
  setSecurityHeaders(response, this.tlsEnabled);
2914
3211
  response.writeHead(304);
@@ -2931,23 +3228,26 @@ var MobileAccessGateway = class {
2931
3228
  }
2932
3229
  if (targetInfo.kind === "action") {
2933
3230
  if (request.method !== "POST") throw new HttpError(405, "method_not_allowed");
2934
- const body = await readJsonObject(request, 1048576);
3231
+ const maximum = 1048576;
3232
+ assertBoundedContentLength(request, maximum);
3233
+ const generation = extensionGeneration(headerValue(request.headers, EXTENSION_GENERATION_HEADER));
2935
3234
  const operation = this.allocateRequest(authorization, response, {});
2936
3235
  const abort = new AbortController();
2937
3236
  response.once("close", () => {
2938
3237
  abort.abort();
2939
3238
  });
2940
- const generationSignal = extensions.signal(targetInfo.id);
3239
+ const generationSignal = extensions.signal(targetInfo.id, generation);
2941
3240
  const onGenerationAbort = () => {
2942
3241
  abort.abort();
2943
3242
  if (!response.destroyed) response.destroy();
2944
3243
  };
2945
3244
  generationSignal?.addEventListener("abort", onGenerationAbort, { once: true });
2946
3245
  try {
3246
+ const body = await readJsonObject(request, maximum);
2947
3247
  const result = await extensions.invoke(targetInfo.id, targetInfo.action, body, {
2948
3248
  signal: abort.signal,
2949
3249
  deviceId: authorization.deviceId
2950
- });
3250
+ }, generation);
2951
3251
  let serialized;
2952
3252
  try {
2953
3253
  serialized = Buffer.from(JSON.stringify(result));
@@ -2973,19 +3273,22 @@ var MobileAccessGateway = class {
2973
3273
  "PATCH",
2974
3274
  "DELETE"
2975
3275
  ].includes(method)) throw new HttpError(405, "method_not_allowed");
2976
- const body = method === "GET" || method === "HEAD" ? Buffer.alloc(0) : await readBoundedBody(request, this.config.maxBodyBytes);
3276
+ const hasBody = method !== "GET" && method !== "HEAD";
3277
+ if (hasBody) assertBoundedContentLength(request, this.config.maxBodyBytes);
3278
+ const generation = extensionGeneration(headerValue(request.headers, EXTENSION_GENERATION_HEADER));
2977
3279
  const operation = this.allocateRequest(authorization, response, {});
2978
3280
  const abort = new AbortController();
2979
3281
  response.once("close", () => {
2980
3282
  abort.abort();
2981
3283
  });
2982
- const generationSignal = extensions.signal(targetInfo.id);
3284
+ const generationSignal = extensions.signal(targetInfo.id, generation);
2983
3285
  const onGenerationAbort = () => {
2984
3286
  abort.abort();
2985
3287
  if (!response.destroyed) response.destroy();
2986
3288
  };
2987
3289
  generationSignal?.addEventListener("abort", onGenerationAbort, { once: true });
2988
3290
  try {
3291
+ const body = hasBody ? await readBoundedBody(request, this.config.maxBodyBytes) : Buffer.alloc(0);
2989
3292
  const parsed = new URL(target.raw, this.address().origin);
2990
3293
  const routeRequest = {
2991
3294
  method,
@@ -2996,7 +3299,7 @@ var MobileAccessGateway = class {
2996
3299
  signal: abort.signal,
2997
3300
  deviceId: authorization.deviceId
2998
3301
  };
2999
- const result = await extensions.route(targetInfo.id, method, targetInfo.path, routeRequest);
3302
+ const result = await extensions.route(targetInfo.id, method, targetInfo.path, routeRequest, generation);
3000
3303
  await this.sendExtensionResponse(response, result, request.method === "HEAD");
3001
3304
  } finally {
3002
3305
  generationSignal?.removeEventListener("abort", onGenerationAbort);
@@ -3382,6 +3685,66 @@ var MobileAccessGateway = class {
3382
3685
  socket.upstream.destroy();
3383
3686
  }
3384
3687
  }
3688
+ broadcastExtensionChange() {
3689
+ if (this.closing) return;
3690
+ this.extensionEventRevision += 1;
3691
+ for (const listener of this.extensionEventListeners) listener(this.extensionEventRevision);
3692
+ }
3693
+ pollLegacyCustomChanges() {
3694
+ if (this.extensionChangeTask !== void 0) return this.extensionChangeTask;
3695
+ const digestFile = async (path, fallback) => {
3696
+ try {
3697
+ const info = await stat(path);
3698
+ if (!info.isFile() || info.size > 262144) return `invalid:${String(info.size)}:${String(info.mtimeMs)}`;
3699
+ return createHash("sha256").update(await readFile(path)).digest("hex");
3700
+ } catch (error) {
3701
+ if (error.code === "ENOENT") return createHash("sha256").update(fallback).digest("hex");
3702
+ return `error:${String(error.code ?? "unknown")}`;
3703
+ }
3704
+ };
3705
+ const task = Promise.all([digestFile(this.config.customScriptFile, CUSTOM_SCRIPT_FALLBACK), digestFile(this.config.customCssFile, CUSTOM_STYLE_FALLBACK)]).then((parts) => {
3706
+ const next = createHash("sha256").update(parts.join("|")).digest("hex");
3707
+ if (this.legacyCustomDigest !== "" && next !== this.legacyCustomDigest) this.broadcastExtensionChange();
3708
+ this.legacyCustomDigest = next;
3709
+ }).finally(() => {
3710
+ if (this.extensionChangeTask === task) this.extensionChangeTask = void 0;
3711
+ });
3712
+ this.extensionChangeTask = task;
3713
+ return task;
3714
+ }
3715
+ openExtensionEventStream(request, response, authorization) {
3716
+ const operation = this.allocateRequest(authorization, response, {});
3717
+ let closed = false;
3718
+ let heartbeat;
3719
+ const close = () => {
3720
+ if (closed) return;
3721
+ closed = true;
3722
+ if (heartbeat !== void 0) clearInterval(heartbeat);
3723
+ this.extensionEventListeners.delete(send);
3724
+ request.removeListener("aborted", close);
3725
+ response.removeListener("close", close);
3726
+ operation.release();
3727
+ };
3728
+ const send = (revision) => {
3729
+ if (closed || response.destroyed || response.writableEnded) return;
3730
+ response.write(`id: ${String(revision)}\nevent: extensions-changed\ndata: {\"revision\":${String(revision)}}\n\n`);
3731
+ };
3732
+ setSecurityHeaders(response, this.tlsEnabled);
3733
+ response.writeHead(200, {
3734
+ "Content-Type": "text/event-stream; charset=utf-8",
3735
+ "Cache-Control": "no-store",
3736
+ Connection: "keep-alive",
3737
+ "X-Accel-Buffering": "no"
3738
+ });
3739
+ response.write("retry: 2000\n: ready\n\n");
3740
+ this.extensionEventListeners.add(send);
3741
+ heartbeat = setInterval(() => {
3742
+ if (!closed && !response.destroyed && !response.writableEnded) response.write(": heartbeat\n\n");
3743
+ }, EXTENSION_EVENT_HEARTBEAT_MS);
3744
+ heartbeat.unref();
3745
+ request.once("aborted", close);
3746
+ response.once("close", close);
3747
+ }
3385
3748
  async readUpgradeResponse(upstream, expectedAccept) {
3386
3749
  return new Promise((resolve, reject) => {
3387
3750
  let buffer = Buffer.alloc(0);
@@ -3629,6 +3992,9 @@ var MobileAccessGateway = class {
3629
3992
  }
3630
3993
  async performClose() {
3631
3994
  this.closing = true;
3995
+ if (this.extensionChangeTimer !== void 0) clearInterval(this.extensionChangeTimer);
3996
+ this.extensionChangeTimer = void 0;
3997
+ this.removeExtensionContentListener();
3632
3998
  this.upstreamAuthRequest?.destroy();
3633
3999
  this.upstreamAuthRequest = void 0;
3634
4000
  this.removeSessionListener();
@@ -3816,10 +4182,12 @@ const REMOTE_ERROR_GUIDANCE = Object.freeze({
3816
4182
  cpolar_exited: "点击“重新连接”;仍失败时复制诊断报告。",
3817
4183
  gateway_start_failed: "确认 DSH 正在运行后重新连接。"
3818
4184
  });
3819
- function check(id, status, label, detail, action) {
4185
+ function check(id, status, reason, label, detail, action, facts) {
3820
4186
  return Object.freeze({
3821
4187
  id,
3822
4188
  status,
4189
+ reason,
4190
+ ...facts === void 0 ? {} : { facts: Object.freeze(facts) },
3823
4191
  label,
3824
4192
  detail,
3825
4193
  ...action === void 0 ? {} : { action }
@@ -3935,24 +4303,42 @@ async function collectConnectionDiagnostics(snapshot, probes = {}) {
3935
4303
  const checks = [];
3936
4304
  const remoteProbe = snapshot.remote.running && snapshot.remote.state === "ready" && snapshot.remote.origin !== void 0 ? (probes.remote ?? defaultRemoteProbe)(snapshot.remote.origin) : Promise.resolve({ state: "not-applicable" });
3937
4305
  const [firewall, remoteObservation] = await Promise.all([(probes.firewall ?? defaultFirewallProbe())(snapshot.lan.port), remoteProbe]);
3938
- checks.push(check("versions", "ok", "版本兼容", `插件 ${DSH_MOBILE_VERSION},DSH ${snapshot.dshVersion},Android App 最低 ${MINIMUM_ANDROID_APP_VERSION}。`));
3939
- if (snapshot.lan.networkError !== void 0) checks.push(check("network", "error", "局域网网卡", "已保存的网卡当前不可用。", "重新运行 dsh-mobile setup。"));
3940
- else if (snapshot.lan.configuredInterface !== void 0) checks.push(check("network", "ok", "局域网网卡", `正在跟随 ${snapshot.lan.interfaceName ?? snapshot.lan.configuredInterface}。`));
3941
- else checks.push(check("network", "info", "局域网网卡", "当前使用固定网络配置。"));
3942
- if (snapshot.lan.running && snapshot.lan.origin !== void 0) checks.push(check("lan", "ok", "局域网网关", `已监听 ${maskLanOrigin(snapshot.lan.origin)},配对入口可用。`));
3943
- else checks.push(check("lan", "info", "局域网网关", "当前未开启。", "需要手机直连时开启局域网访问。"));
3944
- if (firewall.state === "ready") checks.push(check("firewall", "ok", "Windows 防火墙", "局域网 TCP 与发现规则已启用。"));
3945
- else if (firewall.state === "missing") checks.push(check("firewall", "warning", "Windows 防火墙", "未找到完整的局域网放行规则。", "以管理员身份重新运行 dsh-mobile setup。"));
3946
- else if (firewall.state === "unknown") checks.push(check("firewall", "info", "Windows 防火墙", "系统未允许插件读取防火墙状态。", "若手机找不到电脑,以管理员身份重新运行 setup。"));
3947
- if (!snapshot.remote.running || snapshot.remote.state === "off") checks.push(check("remote", "info", "远程通道", "当前未启用。"));
4306
+ checks.push(check("versions", "ok", "versions-current", "版本兼容", `插件 ${DSH_MOBILE_VERSION},DSH ${snapshot.dshVersion},Android App 最低 ${MINIMUM_ANDROID_APP_VERSION}。`));
4307
+ if (snapshot.lan.networkError !== void 0) checks.push(check("network", "error", "network-unavailable", "局域网网卡", "已保存的网卡当前不可用。", "重新运行 dsh-mobile setup。"));
4308
+ else if (snapshot.lan.configuredInterface !== void 0) {
4309
+ const interfaceName = snapshot.lan.interfaceName ?? snapshot.lan.configuredInterface;
4310
+ checks.push(check("network", "ok", "network-interface", "局域网网卡", `正在跟随 ${interfaceName}。`, void 0, { interfaceName }));
4311
+ } else checks.push(check("network", "info", "network-fixed", "局域网网卡", "当前使用固定网络配置。"));
4312
+ if (snapshot.lan.running && snapshot.lan.origin !== void 0) {
4313
+ const endpointSuffix = maskLanOrigin(snapshot.lan.origin);
4314
+ checks.push(check("lan", "ok", "lan-ready", "局域网网关", `已监听 ${endpointSuffix},配对入口可用。`, void 0, { endpointSuffix }));
4315
+ } else checks.push(check("lan", "info", "lan-off", "局域网网关", "当前未开启。", "需要手机直连时开启局域网访问。"));
4316
+ if (firewall.state === "ready") checks.push(check("firewall", "ok", "firewall-ready", "Windows 防火墙", "局域网 TCP 与发现规则已启用。"));
4317
+ else if (firewall.state === "missing") checks.push(check("firewall", "warning", "firewall-missing", "Windows 防火墙", "未找到完整的局域网放行规则。", "以管理员身份重新运行 dsh-mobile setup。"));
4318
+ else if (firewall.state === "unknown") checks.push(check("firewall", "info", "firewall-unknown", "Windows 防火墙", "系统未允许插件读取防火墙状态。", "若手机找不到电脑,以管理员身份重新运行 setup。"));
4319
+ if (!snapshot.remote.running || snapshot.remote.state === "off") checks.push(check("remote", "info", "remote-off", "远程通道", "当前未启用。", void 0, { provider: snapshot.remote.provider }));
3948
4320
  else if (snapshot.remote.state === "ready" && snapshot.remote.origin !== void 0) {
3949
- if (remoteObservation.state === "ready") checks.push(check("remote", "ok", "远程通道", `${snapshot.remote.provider} 公共地址 ${remoteSuffix(snapshot.remote.origin)} 可达,往返约 ${String(remoteObservation.latencyMs ?? 0)} ms。`));
3950
- else if (remoteObservation.state === "rate-limited") checks.push(check("remote", "warning", "远程通道", "公共地址可达,但本次检查观察到服务限流。", "稍后重试;旧会话会按需加载以减少流量。"));
3951
- else if (snapshot.remote.provider === "tailscale" && remoteObservation.fakeIp === true) checks.push(check("remote", "error", "远程通道", "Tailscale 地址被当前 VPN 或 DNS 代理接管,但 TLS 链路未建立。", "切换 VPN 节点或代理模式;仍失败时改用 cpolar。"));
3952
- else checks.push(check("remote", "error", "远程通道", "提供方显示已就绪,但公共地址暂不可达。", "点击“重新连接”;仍失败时检查提供方状态。"));
3953
- } else if (snapshot.remote.state === "starting" || snapshot.remote.state === "connecting" || snapshot.remote.state === "needs-login") checks.push(check("remote", "warning", "远程通道", snapshot.remote.state === "needs-login" ? "等待完成 Tailscale 登录。" : "仍在建立连接。", snapshot.remote.state === "needs-login" ? "返回远程页继续登录。" : "等待片刻后重新检查。"));
3954
- else checks.push(check("remote", "error", "远程通道", `连接未建立(${snapshot.remote.errorCode ?? snapshot.remote.state})。`, REMOTE_ERROR_GUIDANCE[snapshot.remote.errorCode ?? ""] ?? "返回远程页点击“重新连接”。"));
3955
- checks.push(check("phone-network", "info", "手机网络", "电脑无法判断路由器是否隔离了手机。", "局域网仍失败时,确认手机与电脑在同一网络,并关闭访客网络或 AP 隔离。"));
4321
+ const endpointSuffix = remoteSuffix(snapshot.remote.origin);
4322
+ const facts = {
4323
+ provider: snapshot.remote.provider,
4324
+ endpointSuffix,
4325
+ ...remoteObservation.latencyMs === void 0 ? {} : { latencyMs: remoteObservation.latencyMs }
4326
+ };
4327
+ if (remoteObservation.state === "ready") checks.push(check("remote", "ok", "remote-ready", "远程通道", `${snapshot.remote.provider} 公共地址 ${endpointSuffix} 可达,往返约 ${String(remoteObservation.latencyMs ?? 0)} ms。`, void 0, facts));
4328
+ else if (remoteObservation.state === "rate-limited") checks.push(check("remote", "warning", "remote-rate-limited", "远程通道", "公共地址可达,但本次检查观察到服务限流。", "稍后重试;旧会话会按需加载以减少流量。", facts));
4329
+ else if (snapshot.remote.provider === "tailscale" && remoteObservation.fakeIp === true) checks.push(check("remote", "error", "remote-fake-ip", "远程通道", "Tailscale 地址被当前 VPN 或 DNS 代理接管,但 TLS 链路未建立。", "切换 VPN 节点或代理模式;仍失败时改用 cpolar。", facts));
4330
+ else checks.push(check("remote", "error", "remote-unreachable", "远程通道", "提供方显示已就绪,但公共地址暂不可达。", "点击“重新连接”;仍失败时检查提供方状态。", facts));
4331
+ } else if (snapshot.remote.state === "starting" || snapshot.remote.state === "connecting" || snapshot.remote.state === "needs-login") {
4332
+ const needsLogin = snapshot.remote.state === "needs-login";
4333
+ checks.push(check("remote", "warning", needsLogin ? "remote-needs-login" : "remote-connecting", "远程通道", needsLogin ? "等待完成 Tailscale 登录。" : "仍在建立连接。", needsLogin ? "返回远程页继续登录。" : "等待片刻后重新检查。", { provider: snapshot.remote.provider }));
4334
+ } else {
4335
+ const controllerCode = snapshot.remote.errorCode ?? snapshot.remote.state;
4336
+ checks.push(check("remote", "error", "remote-controller-error", "远程通道", `连接未建立(${controllerCode})。`, REMOTE_ERROR_GUIDANCE[controllerCode] ?? "返回远程页点击“重新连接”。", {
4337
+ provider: snapshot.remote.provider,
4338
+ controllerCode
4339
+ }));
4340
+ }
4341
+ checks.push(check("phone-network", "info", "phone-network-unknown", "手机网络", "电脑无法判断路由器是否隔离了手机。", "局域网仍失败时,确认手机与电脑在同一网络,并关闭访客网络或 AP 隔离。"));
3956
4342
  const overall = checks.some((entry) => entry.status === "error") ? "error" : checks.some((entry) => entry.status === "warning") ? "attention" : "ok";
3957
4343
  const summary = overall === "ok" ? "连接基础检查正常。" : overall === "attention" ? "发现需要留意的项目。" : "发现会影响连接的问题。";
3958
4344
  const report = [
@@ -4002,7 +4388,7 @@ const MOBILE_CUSTOMIZATION_GUIDE = `你在为用户定制 DSH Mobile 的手机
4002
4388
  - mobile.js:手机端脚本,用 window.dshMobile.define({ apiVersion:1, id:'<id>', activate(api) { ... } }),activate 返回清理函数
4003
4389
  - mobile.css:手机端样式(可选)
4004
4390
  - assets/:手机端静态资源(可选)
4005
- - mobile.js 里用 api.host.invoke('动作名', 输入) 调 host.mjs 的 action,api.host.fetch('/路由路径') 调 route
4391
+ - mobile.js 里用 api.host.invoke('动作名', 输入) 调 host.mjs 的 action,api.host.fetch('/路由路径') 调 route,api.host.assetUrl('相对路径') 生成与当前版本绑定的资源地址
4006
4392
  - 也可以先用命令生成模板:dsh plugin --profile web exec dsh-mobile extension create <id> --name "<名称>",再在模板上改
4007
4393
 
4008
4394
  安全约束: