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/CHANGELOG.md +14 -0
- package/README.en.md +13 -8
- package/README.md +13 -8
- package/THIRD_PARTY_NOTICES.md +2 -0
- package/bin/dsh-mobile-funnel-win32-x64.exe +0 -0
- package/lib/client.js +2321 -412
- package/lib/client.js.map +1 -1
- package/lib/index.d.mts +32 -9
- package/lib/index.mjs +500 -114
- package/lib/mobile-layout.js +54 -10
- package/lib/mobile-layout.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
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([
|
|
1257
|
-
|
|
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 ?
|
|
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
|
|
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(
|
|
1356
|
-
const [host, script, style] = await Promise.all([
|
|
1357
|
-
optionalBytes(
|
|
1358
|
-
optionalBytes(
|
|
1359
|
-
optionalBytes(
|
|
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:
|
|
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
|
|
1419
|
-
if (first.aborted || second.aborted)
|
|
1420
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1474
|
-
...active.
|
|
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
|
-
|
|
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
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
const body =
|
|
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
|
|
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
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
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:
|
|
1524
|
-
name:
|
|
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
|
|
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 =
|
|
1700
|
+
const routeRequest = lifetime === void 0 ? request : {
|
|
1561
1701
|
...request,
|
|
1562
|
-
signal:
|
|
1563
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1600
|
-
|
|
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
|
-
|
|
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
|
|
1627
|
-
|
|
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
|
|
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
|
-
|
|
1853
|
+
if (removed.length > 0) abortAndDisposeLocal(removed);
|
|
1651
1854
|
this.updateContentHash();
|
|
1652
1855
|
} catch (error) {
|
|
1653
|
-
await
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
1679
|
-
const
|
|
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")
|
|
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
|
-
...
|
|
1735
|
-
...
|
|
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
|
-
|
|
1746
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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)
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
else checks.push(check("
|
|
3944
|
-
if (
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
|
|
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
|
-
|
|
3950
|
-
|
|
3951
|
-
|
|
3952
|
-
|
|
3953
|
-
|
|
3954
|
-
|
|
3955
|
-
|
|
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
|
安全约束:
|