dsh-mobile 0.5.3 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.mjs CHANGED
@@ -16,6 +16,7 @@ import { promisify } from "node:util";
16
16
  import { createGzip, gzip } from "node:zlib";
17
17
  import Bonjour from "bonjour-service";
18
18
  import * as QRCode from "qrcode";
19
+ import WebSocket, { WebSocketServer } from "ws";
19
20
  import { Logger, Service } from "@deepseek-ai/cordis";
20
21
  import { connect as connect$1 } from "node:tls";
21
22
  import { generate } from "selfsigned";
@@ -241,15 +242,10 @@ var AccessController = class {
241
242
  /** Exchange a valid persistent device credential for a new short Session. */
242
243
  async renew(deviceToken) {
243
244
  this.requireInitialized();
244
- if (deviceToken.length > 512) throw new AccessError(401, "authentication_failed");
245
245
  return this.exclusive(async () => {
246
246
  const now = this.now();
247
- const tokenDigest = digest(deviceToken);
248
- const index = this.devices.findIndex((device) => timingSafeEqual(Buffer.from(device.tokenDigest, "hex"), tokenDigest));
249
- const device = this.devices[index];
250
- if (device === void 0) throw new AccessError(401, "authentication_failed");
251
- if (device.revokedAt !== void 0) throw new AccessError(401, "device_revoked");
252
- if (device.expiresAt <= now) throw new AccessError(401, "device_expired");
247
+ const device = this.requireDevice(deviceToken, now);
248
+ const index = this.devices.indexOf(device);
253
249
  const updated = Object.freeze({
254
250
  ...device,
255
251
  lastSeenAt: now
@@ -264,15 +260,10 @@ var AccessController = class {
264
260
  /** Validate a persistent device credential without consuming a Session slot. */
265
261
  async probe(deviceToken) {
266
262
  this.requireInitialized();
267
- if (deviceToken.length > 512) throw new AccessError(401, "authentication_failed");
268
263
  return this.exclusive(async () => {
269
264
  const now = this.now();
270
- const tokenDigest = digest(deviceToken);
271
- const index = this.devices.findIndex((device) => timingSafeEqual(Buffer.from(device.tokenDigest, "hex"), tokenDigest));
272
- const device = this.devices[index];
273
- if (device === void 0) throw new AccessError(401, "authentication_failed");
274
- if (device.revokedAt !== void 0) throw new AccessError(401, "device_revoked");
275
- if (device.expiresAt <= now) throw new AccessError(401, "device_expired");
265
+ const device = this.requireDevice(deviceToken, now);
266
+ const index = this.devices.indexOf(device);
276
267
  const updated = Object.freeze({
277
268
  ...device,
278
269
  lastSeenAt: now
@@ -287,6 +278,24 @@ var AccessController = class {
287
278
  });
288
279
  });
289
280
  }
281
+ /** Validate a device credential without writing state or opening a Session. */
282
+ authorizeDevice(deviceToken) {
283
+ this.requireInitialized();
284
+ const device = this.requireDevice(deviceToken, this.now());
285
+ return Object.freeze({
286
+ deviceId: device.id,
287
+ deviceExpiresAt: device.expiresAt
288
+ });
289
+ }
290
+ requireDevice(deviceToken, now) {
291
+ if (deviceToken.length > 512) throw new AccessError(401, "authentication_failed");
292
+ const tokenDigest = digest(deviceToken);
293
+ const device = this.devices.find((candidate) => timingSafeEqual(Buffer.from(candidate.tokenDigest, "hex"), tokenDigest));
294
+ if (device === void 0) throw new AccessError(401, "authentication_failed");
295
+ if (device.revokedAt !== void 0) throw new AccessError(401, "device_revoked");
296
+ if (device.expiresAt <= now) throw new AccessError(401, "device_expired");
297
+ return device;
298
+ }
290
299
  /** Resolve a short Session Cookie without revealing whether device or Session failed. */
291
300
  authorizeSession(sessionToken) {
292
301
  this.requireInitialized();
@@ -745,6 +754,112 @@ async function probeTcpReachable(host, port, timeoutMs = 1500) {
745
754
  });
746
755
  }
747
756
  //#endregion
757
+ //#region src/websocket-paths.ts
758
+ /** Cap the admin-approved extra WebSocket upgrade paths (exact pathnames). */
759
+ const MAX_EXTRA_WEBSOCKET_PATHS = 16;
760
+ const MAX_WEBSOCKET_PATH_LENGTH = 256;
761
+ function fail(code) {
762
+ throw new Error(code);
763
+ }
764
+ /**
765
+ * Validate one exact pathname for proxying. Query strings are matched at
766
+ * upgrade time, so only the pathname is stored. Rejects anything the
767
+ * gateway cannot match exactly.
768
+ */
769
+ function validateWebSocketPath(value) {
770
+ if (typeof value !== "string") fail("websocket_path_invalid");
771
+ const path = value;
772
+ if (path.length === 0 || path.length > 256) fail("websocket_path_invalid");
773
+ if (!path.startsWith("/")) fail("websocket_path_invalid");
774
+ if (/[\s\u0000-\u001f\u007f#?]/u.test(path)) fail("websocket_path_invalid");
775
+ if (path.includes("..")) fail("websocket_path_invalid");
776
+ return path;
777
+ }
778
+ /** Validate a whole replacement list all-or-nothing; duplicates collapse. */
779
+ function normalizeWebSocketPaths(value) {
780
+ if (!Array.isArray(value)) fail("websocket_paths_invalid");
781
+ if (value.length > 16) fail("websocket_paths_invalid");
782
+ const seen = /* @__PURE__ */ new Set();
783
+ for (const entry of value) {
784
+ const path = validateWebSocketPath(entry);
785
+ seen.add(path);
786
+ }
787
+ return [...seen];
788
+ }
789
+ /** Cap remembered rejected upgrade paths offered for one-click approval. */
790
+ const MAX_BLOCKED_UPGRADE_PATHS = 32;
791
+ /**
792
+ * In-memory log of rejected third-party upgrade paths, shared by every
793
+ * gateway instance so the approval UI sees attempts on any listener.
794
+ * Bounded and newest-first; a restart clears it (attempts reappear on
795
+ * the next blocked handshake).
796
+ */
797
+ var BlockedUpgradePathLog = class {
798
+ entries = /* @__PURE__ */ new Map();
799
+ record(pathname) {
800
+ const now = Date.now();
801
+ const stats = this.entries.get(pathname);
802
+ if (stats === void 0) {
803
+ if (this.entries.size >= 32) {
804
+ let oldest;
805
+ for (const [path, entry] of this.entries) if (oldest === void 0 || entry.lastSeen < (this.entries.get(oldest)?.lastSeen ?? 0)) oldest = path;
806
+ if (oldest !== void 0) this.entries.delete(oldest);
807
+ }
808
+ this.entries.set(pathname, {
809
+ attempts: 1,
810
+ firstSeen: now,
811
+ lastSeen: now
812
+ });
813
+ return;
814
+ }
815
+ stats.attempts += 1;
816
+ stats.lastSeen = now;
817
+ }
818
+ report() {
819
+ return [...this.entries].map(([path, stats]) => ({
820
+ path,
821
+ ...stats
822
+ })).sort((a, b) => b.lastSeen - a.lastSeen);
823
+ }
824
+ };
825
+ /** File-backed store shared by every gateway instance (LAN and remote). */
826
+ var WebSocketPathStore = class {
827
+ file;
828
+ paths = /* @__PURE__ */ new Set();
829
+ loaded = false;
830
+ constructor(file) {
831
+ this.file = file;
832
+ }
833
+ /** Snapshot for the upgrade check. */
834
+ has(pathname) {
835
+ return this.paths.has(pathname);
836
+ }
837
+ list() {
838
+ return [...this.paths];
839
+ }
840
+ async load() {
841
+ if (!this.loaded) {
842
+ try {
843
+ const raw = JSON.parse(await readFile(this.file, "utf8"));
844
+ this.paths = new Set(normalizeWebSocketPaths(raw.paths ?? []));
845
+ } catch {
846
+ this.paths = /* @__PURE__ */ new Set();
847
+ }
848
+ this.loaded = true;
849
+ }
850
+ return this.list();
851
+ }
852
+ /** Replace the whole list after validating; persists atomically. */
853
+ async replace(paths) {
854
+ const next = normalizeWebSocketPaths([...paths]);
855
+ await mkdir(dirname(this.file), { recursive: true });
856
+ await writeFile(this.file, `${JSON.stringify({ paths: next })}\n`, "utf8");
857
+ this.paths = new Set(next);
858
+ this.loaded = true;
859
+ return this.list();
860
+ }
861
+ };
862
+ //#endregion
748
863
  //#region src/config.ts
749
864
  /** Loader-facing defaults; {@link parseGatewayConfig} enforces cross-field security rules. */
750
865
  const Config = z.object({
@@ -762,6 +877,14 @@ const Config = z.object({
762
877
  mobileLayoutFile: z.string().hidden(),
763
878
  mobileCompatibilityFile: z.string().hidden(),
764
879
  excludedClientModules: z.array(String).default([]),
880
+ websocketCompression: z.object({
881
+ paths: z.array(String).default([]),
882
+ maxMessageBytes: z.natural(),
883
+ maxQueuedBytes: z.natural(),
884
+ thresholdBytes: z.natural(),
885
+ concurrencyLimit: z.natural(),
886
+ level: z.natural().max(9)
887
+ }),
765
888
  instanceId: z.string().hidden(),
766
889
  pairingCaFile: z.string().hidden(),
767
890
  initiallyEnabled: z.boolean().hidden().required(),
@@ -790,6 +913,21 @@ function integer(value, name, fallback, minimum, maximum) {
790
913
  if (typeof resolved !== "number" || !Number.isSafeInteger(resolved) || resolved < minimum || resolved > maximum) throw new Error(`${name} must be an integer from ${String(minimum)} through ${String(maximum)}`);
791
914
  return resolved;
792
915
  }
916
+ function parseWebSocketCompression(raw) {
917
+ if (raw !== void 0 && (typeof raw !== "object" || raw === null || Array.isArray(raw))) throw new Error("websocketCompression must be an object");
918
+ const value = raw ?? {};
919
+ const maxMessageBytes = integer(value.maxMessageBytes, "websocketCompression.maxMessageBytes", 33554432, 1024, 268435456);
920
+ const maxQueuedBytes = integer(value.maxQueuedBytes, "websocketCompression.maxQueuedBytes", 67108864, 1024, 268435456);
921
+ if (maxQueuedBytes < maxMessageBytes + 14) throw new Error("websocketCompression.maxQueuedBytes must cover maxMessageBytes and the frame header");
922
+ return Object.freeze({
923
+ paths: Object.freeze(normalizeWebSocketPaths(value.paths ?? [])),
924
+ maxMessageBytes,
925
+ maxQueuedBytes,
926
+ thresholdBytes: integer(value.thresholdBytes, "websocketCompression.thresholdBytes", 1024, 0, maxMessageBytes),
927
+ concurrencyLimit: integer(value.concurrencyLimit, "websocketCompression.concurrencyLimit", 4, 1, 32),
928
+ level: integer(value.level, "websocketCompression.level", 3, 0, 9)
929
+ });
930
+ }
793
931
  function stringArray(value, name) {
794
932
  if (!Array.isArray(value) || value.length === 0 || value.some((entry) => typeof entry !== "string")) throw new Error(`${name} must be a non-empty string array`);
795
933
  return value;
@@ -896,6 +1034,7 @@ function parseGatewayConfig(raw) {
896
1034
  mobileLayoutFile: value.mobileLayoutFile === void 0 ? fileURLToPath(new URL("./mobile-layout.js", import.meta.url)) : absoluteFile(value.mobileLayoutFile, "mobileLayoutFile"),
897
1035
  mobileCompatibilityFile: value.mobileCompatibilityFile === void 0 ? fileURLToPath(new URL("./mobile-compat.js", import.meta.url)) : absoluteFile(value.mobileCompatibilityFile, "mobileCompatibilityFile"),
898
1036
  excludedClientModules: excludedClientModules(value.excludedClientModules),
1037
+ websocketCompression: parseWebSocketCompression(value.websocketCompression),
899
1038
  instanceId: value.instanceId === void 0 ? createHash("sha256").update(absoluteFile(value.stateFile, "stateFile")).digest("hex") : /^[a-f\d]{64}$/u.test(value.instanceId) ? value.instanceId : (() => {
900
1039
  throw new Error("instanceId must be a lowercase SHA-256 value");
901
1040
  })(),
@@ -1402,21 +1541,6 @@ async function readJsonObject(request, maximumBytes) {
1402
1541
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new HttpError(400, "bad_request");
1403
1542
  return parsed;
1404
1543
  }
1405
- /** Strict cookie parser: malformed or duplicate names invalidate the whole header. */
1406
- function parseCookies(header) {
1407
- if (header === void 0) return /* @__PURE__ */ new Map();
1408
- if (header.length > 8192) return void 0;
1409
- const cookies = /* @__PURE__ */ new Map();
1410
- for (const part of header.split(";")) {
1411
- const equals = part.indexOf("=");
1412
- if (equals <= 0) return void 0;
1413
- const name = part.slice(0, equals).trim();
1414
- const value = part.slice(equals + 1).trim();
1415
- if (!/^[!#$%&'*+\-.^_`|~\dA-Za-z]+$/u.test(name) || !/^[\w\-.~+/=]*$/u.test(value) || cookies.has(name)) return;
1416
- cookies.set(name, value);
1417
- }
1418
- return cookies;
1419
- }
1420
1544
  /** Serialize a host-only Cookie with no Domain attribute. */
1421
1545
  function cookie(name, value, options) {
1422
1546
  const parts = [
@@ -1516,6 +1640,280 @@ function ensureMobileCompatibility(html) {
1516
1640
  return `${html.slice(0, first.index)}${script}${html.slice(first.index)}`;
1517
1641
  }
1518
1642
  //#endregion
1643
+ //#region src/browser-auth-cookies.ts
1644
+ const HOST_SESSION_COOKIE = "__Host-dsh_ma_session";
1645
+ const HOST_CSRF_COOKIE = "__Host-dsh_ma_csrf";
1646
+ /** Non-secret browser identity; the persistent device credential keeps its narrow renewal path. */
1647
+ const HOST_DEVICE_COOKIE = "__Host-dsh_ma_device_id";
1648
+ const AUTH_COOKIE_NAMES = /* @__PURE__ */ new Set([
1649
+ SESSION_COOKIE,
1650
+ DEVICE_COOKIE,
1651
+ CSRF_COOKIE,
1652
+ HOST_SESSION_COOKIE,
1653
+ HOST_CSRF_COOKIE,
1654
+ HOST_DEVICE_COOKIE
1655
+ ]);
1656
+ /** Read only gateway authentication cookies, retaining distinct values from different scopes. */
1657
+ function browserAuthCookies(header) {
1658
+ if (header === void 0) return /* @__PURE__ */ new Map();
1659
+ if (header.length > 8192) throw new HttpError(401, "authentication_failed");
1660
+ const cookies = /* @__PURE__ */ new Map();
1661
+ for (const part of header.split(";")) {
1662
+ const equals = part.indexOf("=");
1663
+ if (equals <= 0) continue;
1664
+ const name = part.slice(0, equals).trim();
1665
+ if (!AUTH_COOKIE_NAMES.has(name)) continue;
1666
+ const values = cookies.get(name) ?? [];
1667
+ cookies.set(name, values);
1668
+ const value = part.slice(equals + 1).trim();
1669
+ if (!/^[\w\-.~+/=]{1,512}$/u.test(value) || values.includes(value)) continue;
1670
+ values.push(value);
1671
+ }
1672
+ return cookies;
1673
+ }
1674
+ /** Select exactly one authenticated value; never let cookie order choose between valid identities. */
1675
+ function authenticatedCookie(values, authenticate, accepts = () => true) {
1676
+ let selected;
1677
+ for (const value of values) {
1678
+ let result;
1679
+ try {
1680
+ result = authenticate(value);
1681
+ } catch (error) {
1682
+ if (error instanceof AccessError && error.status === 401) continue;
1683
+ throw error;
1684
+ }
1685
+ if (!accepts(result)) continue;
1686
+ if (selected !== void 0) throw new HttpError(401, "authentication_failed");
1687
+ selected = {
1688
+ value,
1689
+ result
1690
+ };
1691
+ }
1692
+ if (selected === void 0) throw new HttpError(401, "authentication_failed");
1693
+ return selected;
1694
+ }
1695
+ //#endregion
1696
+ //#region src/websocket-compression.ts
1697
+ function payloadBytes(data) {
1698
+ if (Array.isArray(data)) return data.reduce((bytes, part) => bytes + part.byteLength, 0);
1699
+ return data.byteLength;
1700
+ }
1701
+ function forwardableClose(code) {
1702
+ return code >= 1e3 && code <= 1014 && code !== 1004 && code !== 1005 && code !== 1006 || code >= 3e3 && code <= 4999;
1703
+ }
1704
+ /**
1705
+ * Bridge an authorized HTTP upgrade, negotiating compression only with the client.
1706
+ * Errors after acceptance close both legs. Socket destruction by the gateway also
1707
+ * releases the ws wrappers, pending compression, and handshake/close timers.
1708
+ * @param request - validated browser upgrade request.
1709
+ * @param client - gateway-owned client socket, paused during authorization.
1710
+ * @param upstream - gateway-owned loopback socket, connecting or connected.
1711
+ * @param head - bytes the HTTP parser read after the client's upgrade headers.
1712
+ * @param target - validated upstream URL, including the original query.
1713
+ * @param upstreamCookie - gateway's signed upstream cookie; browser cookies are discarded.
1714
+ * @param options - resolved message, queue, and compression limits.
1715
+ * @param handshakeTimeoutMs - maximum handshake and close-handshake duration.
1716
+ * @returns fulfillment after both HTTP upgrades are accepted, or rejection before acceptance.
1717
+ */
1718
+ function bridgeCompressedWebSocket(request, client, upstream, head, target, upstreamCookie, options, handshakeTimeoutMs) {
1719
+ return new Promise((resolve, reject) => {
1720
+ let incoming;
1721
+ let outgoing;
1722
+ let server;
1723
+ let accepted = false;
1724
+ let stopped = false;
1725
+ let closing = false;
1726
+ let closeTimer;
1727
+ const handshakeTimer = setTimeout(() => {
1728
+ stop(/* @__PURE__ */ new Error("WebSocket bridge handshake timed out"));
1729
+ }, handshakeTimeoutMs);
1730
+ handshakeTimer.unref();
1731
+ const stop = (error) => {
1732
+ if (stopped) return;
1733
+ stopped = true;
1734
+ clearTimeout(handshakeTimer);
1735
+ clearTimeout(closeTimer);
1736
+ if (!accepted) reject(error);
1737
+ incoming?.terminate();
1738
+ outgoing?.terminate();
1739
+ client.destroy();
1740
+ upstream.destroy();
1741
+ server?.close();
1742
+ };
1743
+ const finish = () => {
1744
+ if (incoming?.readyState === WebSocket.CLOSED && outgoing?.readyState === WebSocket.CLOSED) stop(/* @__PURE__ */ new Error("WebSocket bridge closed"));
1745
+ };
1746
+ const beginClose = (code, reason) => {
1747
+ if (!accepted) {
1748
+ stop(new Error(reason));
1749
+ return;
1750
+ }
1751
+ if (stopped || closing) return;
1752
+ closing = true;
1753
+ incoming?.resume();
1754
+ outgoing?.resume();
1755
+ for (const socket of [incoming, outgoing]) if (socket?.readyState === WebSocket.OPEN) socket.close(code, reason);
1756
+ closeTimer = setTimeout(() => {
1757
+ stop(/* @__PURE__ */ new Error("WebSocket bridge close timed out"));
1758
+ }, handshakeTimeoutMs);
1759
+ closeTimer.unref();
1760
+ };
1761
+ const socketError = (error) => {
1762
+ stop(error);
1763
+ };
1764
+ const socketClosed = () => {
1765
+ if (!accepted) stop(/* @__PURE__ */ new Error("WebSocket socket closed before acceptance"));
1766
+ };
1767
+ client.on("error", socketError);
1768
+ upstream.on("error", socketError);
1769
+ client.once("close", socketClosed);
1770
+ upstream.once("close", socketClosed);
1771
+ const wsError = (error) => {
1772
+ if (!accepted) {
1773
+ stop(error);
1774
+ return;
1775
+ }
1776
+ beginClose(error.code === "WS_ERR_UNSUPPORTED_MESSAGE_LENGTH" ? 1009 : 1011, error.code === "WS_ERR_UNSUPPORTED_MESSAGE_LENGTH" ? "Message too large" : "WebSocket bridge failed");
1777
+ };
1778
+ const attachRelay = (source, destination) => {
1779
+ let queuedBytes = 0;
1780
+ const send = (bytes, operation) => {
1781
+ if (stopped || closing) return;
1782
+ if (destination.readyState !== WebSocket.OPEN) {
1783
+ beginClose(1011, "WebSocket peer unavailable");
1784
+ return;
1785
+ }
1786
+ const queued = bytes + 14;
1787
+ if (queued > options.maxQueuedBytes - queuedBytes) {
1788
+ beginClose(1009, "WebSocket queue limit");
1789
+ return;
1790
+ }
1791
+ queuedBytes += queued;
1792
+ source.pause();
1793
+ const complete = (error) => {
1794
+ queuedBytes -= queued;
1795
+ if (error != null) {
1796
+ wsError(error);
1797
+ return;
1798
+ }
1799
+ if (!stopped && !closing && queuedBytes === 0 && source.readyState === WebSocket.OPEN) source.resume();
1800
+ };
1801
+ try {
1802
+ operation(complete);
1803
+ } catch (error) {
1804
+ complete(error instanceof Error ? error : /* @__PURE__ */ new Error("WebSocket forwarding failed"));
1805
+ }
1806
+ };
1807
+ source.on("message", (data, binary) => {
1808
+ const bytes = payloadBytes(data);
1809
+ if (bytes > options.maxMessageBytes) {
1810
+ beginClose(1009, "Message too large");
1811
+ return;
1812
+ }
1813
+ send(bytes, (callback) => {
1814
+ destination.send(data, {
1815
+ binary,
1816
+ compress: destination === incoming
1817
+ }, callback);
1818
+ });
1819
+ });
1820
+ source.on("ping", (data) => {
1821
+ send(data.byteLength, (callback) => {
1822
+ destination.ping(data, void 0, callback);
1823
+ });
1824
+ });
1825
+ source.on("pong", (data) => {
1826
+ send(data.byteLength, (callback) => {
1827
+ destination.pong(data, void 0, callback);
1828
+ });
1829
+ });
1830
+ source.on("close", (code, reason) => {
1831
+ if (stopped) return;
1832
+ if (code === 1006 || !forwardableClose(code) && code !== 1005) {
1833
+ stop(/* @__PURE__ */ new Error("WebSocket peer closed without a valid close frame"));
1834
+ return;
1835
+ }
1836
+ closing = true;
1837
+ destination.resume();
1838
+ if (destination.readyState === WebSocket.OPEN) {
1839
+ if (code === 1005) destination.close();
1840
+ else destination.close(code, reason);
1841
+ }
1842
+ closeTimer ??= setTimeout(() => {
1843
+ stop(/* @__PURE__ */ new Error("WebSocket bridge close timed out"));
1844
+ }, handshakeTimeoutMs);
1845
+ closeTimer.unref();
1846
+ finish();
1847
+ });
1848
+ };
1849
+ try {
1850
+ if (client.destroyed || upstream.destroyed) throw new Error("WebSocket socket is already closed");
1851
+ const protocolHeader = request.headers["sec-websocket-protocol"];
1852
+ const protocols = typeof protocolHeader === "string" ? protocolHeader.split(",").map((protocol) => protocol.trim()) : [];
1853
+ const url = new URL(target);
1854
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
1855
+ outgoing = new WebSocket(url, protocols, {
1856
+ createConnection: () => upstream,
1857
+ handshakeTimeout: handshakeTimeoutMs,
1858
+ perMessageDeflate: false,
1859
+ autoPong: false,
1860
+ followRedirects: false,
1861
+ maxPayload: options.maxMessageBytes,
1862
+ headers: {
1863
+ host: target.host,
1864
+ origin: target.origin,
1865
+ "sec-fetch-site": "same-origin",
1866
+ ...upstreamCookie === void 0 ? {} : { cookie: upstreamCookie }
1867
+ }
1868
+ });
1869
+ outgoing.on("error", wsError);
1870
+ outgoing.once("close", () => {
1871
+ if (!accepted) stop(/* @__PURE__ */ new Error("Upstream WebSocket closed before acceptance"));
1872
+ else finish();
1873
+ });
1874
+ outgoing.once("open", () => {
1875
+ if (stopped || client.destroyed) {
1876
+ stop(/* @__PURE__ */ new Error("Client closed before WebSocket acceptance"));
1877
+ return;
1878
+ }
1879
+ upstream.setTimeout(0);
1880
+ server = new WebSocketServer({
1881
+ noServer: true,
1882
+ clientTracking: false,
1883
+ autoPong: false,
1884
+ maxPayload: options.maxMessageBytes,
1885
+ handleProtocols: () => outgoing?.protocol || false,
1886
+ perMessageDeflate: {
1887
+ clientNoContextTakeover: true,
1888
+ serverNoContextTakeover: true,
1889
+ threshold: options.thresholdBytes,
1890
+ concurrencyLimit: options.concurrencyLimit,
1891
+ zlibDeflateOptions: { level: options.level }
1892
+ }
1893
+ });
1894
+ server.on("error", wsError);
1895
+ try {
1896
+ server.handleUpgrade(request, client, head, (socket) => {
1897
+ incoming = socket;
1898
+ incoming.on("error", wsError);
1899
+ incoming.once("close", finish);
1900
+ attachRelay(incoming, outgoing);
1901
+ attachRelay(outgoing, incoming);
1902
+ accepted = true;
1903
+ clearTimeout(handshakeTimer);
1904
+ incoming.resume();
1905
+ resolve();
1906
+ });
1907
+ } catch (error) {
1908
+ stop(error instanceof Error ? error : /* @__PURE__ */ new Error("Client WebSocket upgrade failed"));
1909
+ }
1910
+ });
1911
+ } catch (error) {
1912
+ stop(error instanceof Error ? error : /* @__PURE__ */ new Error("WebSocket bridge setup failed"));
1913
+ }
1914
+ });
1915
+ }
1916
+ //#endregion
1519
1917
  //#region src/version.ts
1520
1918
  const manifest = createRequire(import.meta.url)("../package.json");
1521
1919
  /** Version of the installed DSH Mobile plugin package. */
@@ -2577,8 +2975,6 @@ function renderLoginScript(locale) {
2577
2975
  //#region src/gateway.ts
2578
2976
  const MAX_CONTROL_BODY_BYTES = 16384;
2579
2977
  const MAX_HEADER_BYTES = 16384;
2580
- const MOBILE_HISTORY_PAGE_MESSAGES = 10;
2581
- const SESSION_HISTORY_PATH = "/api/session.history";
2582
2978
  const DISCOVERY_QUERY = Buffer.from("DSH_MOBILE_DISCOVER_V1", "ascii");
2583
2979
  const DISCOVERY_PROTOCOL = 1;
2584
2980
  const DISCOVERY_INTERVAL_MS = 3e3;
@@ -2664,7 +3060,9 @@ const MOBILE_LAYOUT_DEPENDENCY_PROFILES = Object.freeze([Object.freeze({
2664
3060
  "@deepseek-ai/dsh-client-ui-theme"
2665
3061
  ])
2666
3062
  })]);
2667
- const MOBILE_CSRF_FETCH_BOOTSTRAP = `(()=>{const nativeFetch=window.fetch.bind(window);window.fetch=(input,init)=>{const source=input instanceof Request?input:undefined;const method=String(init?.method??source?.method??'GET').toUpperCase();if(method==='GET'||method==='HEAD')return nativeFetch(input,init);const raw=typeof input==='string'?input:input instanceof URL?input.href:source?.url;if(raw===undefined||new URL(raw,location.href).origin!==location.origin)return nativeFetch(input,init);const headers=new Headers(init?.headers??source?.headers);if(!headers.has(${JSON.stringify(CSRF_HEADER)})){const prefix=${JSON.stringify(`${CSRF_COOKIE}=`)};const token=document.cookie.split(';').map(value=>value.trim()).find(value=>value.startsWith(prefix))?.slice(prefix.length);if(token!==undefined)headers.set(${JSON.stringify(CSRF_HEADER)},token)}return nativeFetch(input,{...init,headers})};})();`;
3063
+ function mobileCsrfFetchBootstrap(cookieName) {
3064
+ return `(()=>{const nativeFetch=window.fetch.bind(window);window.fetch=(input,init)=>{const source=input instanceof Request?input:undefined;const method=String(init?.method??source?.method??'GET').toUpperCase();if(method==='GET'||method==='HEAD')return nativeFetch(input,init);const raw=typeof input==='string'?input:input instanceof URL?input.href:source?.url;if(raw===undefined||new URL(raw,location.href).origin!==location.origin)return nativeFetch(input,init);const headers=new Headers(init?.headers??source?.headers);if(!headers.has(${JSON.stringify(CSRF_HEADER)})){const prefix=${JSON.stringify(`${cookieName}=`)};const values=[...new Set(document.cookie.split(';').map(value=>value.trim()).filter(value=>value.startsWith(prefix)).map(value=>value.slice(prefix.length)))];if(values.length===1)headers.set(${JSON.stringify(CSRF_HEADER)},values[0])}return nativeFetch(input,{...init,headers})};})();`;
3065
+ }
2668
3066
  const MOBILE_AUTHENTICATED_TRANSPORT_BOOTSTRAP = `(()=>{if(window.__DSH_TRANSPORT__!==undefined)throw new Error('DSH Mobile cannot replace an existing transport override');window.__DSH_TRANSPORT__={fetch:(input,init)=>window.fetch(input,init),ownsHost:true},window.__DSH_FILE_UPLOAD__={fetch:(input,init)=>window.fetch(input,init)}})();`;
2669
3067
  /** Use this page's gateway health, rather than OS internet validation, for DSH reconnects. */
2670
3068
  const MOBILE_GATEWAY_REACHABILITY_BOOTSTRAP = `(()=>{
@@ -2976,7 +3374,7 @@ function rewriteMobileIndexWithBatch(html, options = {}) {
2976
3374
  batches: plan.batches
2977
3375
  })).digest("hex").slice(0, 16);
2978
3376
  }
2979
- const replacement = `${MOBILE_GATEWAY_REACHABILITY_BOOTSTRAP}${remoteSettings ? MOBILE_AUTHENTICATED_TRANSPORT_BOOTSTRAP : ""}${MOBILE_CSRF_FETCH_BOOTSTRAP}window.__DSH_MOBILE_FRONTEND__="dedicated";${plan.assignment}${JSON.stringify(plan.parsed)};`;
3377
+ const replacement = `${MOBILE_GATEWAY_REACHABILITY_BOOTSTRAP}${remoteSettings ? MOBILE_AUTHENTICATED_TRANSPORT_BOOTSTRAP : ""}${mobileCsrfFetchBootstrap(options.csrfCookie ?? "dsh_ma_csrf")}window.__DSH_MOBILE_FRONTEND__="dedicated";${plan.assignment}${JSON.stringify(plan.parsed)};`;
2980
3378
  const rewritten = `${html.slice(0, plan.replaceStart)}${replacement}${html.slice(plan.replaceEnd)}`;
2981
3379
  return Object.freeze({
2982
3380
  html: ensureMobileViewport(ensureMobileCompatibility(rewriteMobileApplicationPreloads(rewritten, preloadReplacements))),
@@ -3177,7 +3575,7 @@ function isCompressibleContentType(value) {
3177
3575
  }
3178
3576
  function shouldCompressResponse(request, response) {
3179
3577
  const pathname = request.url?.split("?", 1)[0] ?? "";
3180
- return (request.method === "GET" && (pathname.startsWith("/plugins/") || pathname.startsWith("/assets/")) || request.method === "POST" && pathname === SESSION_HISTORY_PATH) && response.statusCode === 200 && request.headers.range === void 0 && response.headers["content-range"] === void 0 && response.headers["content-encoding"] === void 0 && acceptsGzip(request.headers["accept-encoding"]) && isCompressibleContentType(response.headers["content-type"]);
3578
+ return request.method === "GET" && (pathname.startsWith("/plugins/") || pathname.startsWith("/assets/")) && response.statusCode === 200 && request.headers.range === void 0 && response.headers["content-range"] === void 0 && response.headers["content-encoding"] === void 0 && acceptsGzip(request.headers["accept-encoding"]) && isCompressibleContentType(response.headers["content-type"]);
3181
3579
  }
3182
3580
  function revisionedStaticCacheControl(request, statusCode) {
3183
3581
  if (request.method !== "GET" && request.method !== "HEAD") return void 0;
@@ -3194,39 +3592,12 @@ function revisionedStaticCacheControl(request, statusCode) {
3194
3592
  if (!(target.pathname.startsWith("/plugins/") && hasRevision) && !(target.pathname.startsWith("/assets/") && (hasRevision || hashedAsset))) return void 0;
3195
3593
  return "private, max-age=31536000, immutable";
3196
3594
  }
3197
- function isJsonRecord(value) {
3198
- return typeof value === "object" && value !== null && !Array.isArray(value);
3199
- }
3200
- function mobileHistoryRequestBody(request, body) {
3201
- if (request.method !== "POST" || request.url?.split("?", 1)[0] !== SESSION_HISTORY_PATH) return body;
3202
- let parsed;
3203
- try {
3204
- parsed = JSON.parse(body.toString("utf8"));
3205
- } catch {
3206
- return body;
3207
- }
3208
- if (!isJsonRecord(parsed) || parsed.method !== "session.history" || !isJsonRecord(parsed.payload)) return body;
3209
- const requested = parsed.payload.maxMessages;
3210
- if (typeof requested === "number" && Number.isInteger(requested) && requested > 0 && requested <= MOBILE_HISTORY_PAGE_MESSAGES) return body;
3211
- return Buffer.from(JSON.stringify({
3212
- ...parsed,
3213
- payload: {
3214
- ...parsed.payload,
3215
- maxMessages: MOBILE_HISTORY_PAGE_MESSAGES
3216
- }
3217
- }));
3218
- }
3219
3595
  function addVaryAcceptEncoding(headers) {
3220
3596
  const existing = headers.vary;
3221
3597
  const values = (Array.isArray(existing) ? existing.map((value) => String(value)) : existing === void 0 ? [] : [String(existing)]).flatMap((value) => value.split(",").map((part) => part.trim()).filter(Boolean));
3222
3598
  if (!values.some((value) => value.toLowerCase() === "accept-encoding")) values.push("Accept-Encoding");
3223
3599
  headers.vary = values.join(", ");
3224
3600
  }
3225
- function requestCookies(request) {
3226
- const cookies = parseCookies(request.headers.cookie);
3227
- if (cookies === void 0) throw new HttpError(401, "authentication_failed");
3228
- return cookies;
3229
- }
3230
3601
  function mapError(error) {
3231
3602
  if (error instanceof HttpError) return error;
3232
3603
  if (error instanceof AccessError) return new HttpError(error.status, error.code);
@@ -3755,9 +4126,9 @@ var MobileAccessGateway = class {
3755
4126
  return this.policy;
3756
4127
  }
3757
4128
  authorize(request) {
3758
- const sessionToken = requestCookies(request).get(SESSION_COOKIE);
3759
- if (sessionToken === void 0) throw new HttpError(401, "authentication_failed");
3760
- return this.access.authorizeSession(sessionToken);
4129
+ const cookies = browserAuthCookies(request.headers.cookie);
4130
+ const name = this.tlsEnabled && cookies.has("__Host-dsh_ma_session") ? HOST_SESSION_COOKIE : SESSION_COOKIE;
4131
+ return authenticatedCookie(cookies.get(name) ?? [], (token) => this.access.authorizeSession(token)).result;
3761
4132
  }
3762
4133
  requireCsrf(request, authorization) {
3763
4134
  const value = headerValue(request.headers, CSRF_HEADER);
@@ -3765,16 +4136,40 @@ var MobileAccessGateway = class {
3765
4136
  }
3766
4137
  setSessionCookies(response, result, now) {
3767
4138
  const maxAge = (result.sessionExpiresAt - now) / 1e3;
3768
- response.setHeader("Set-Cookie", [cookie(SESSION_COOKIE, result.sessionToken, {
3769
- tls: this.tlsEnabled,
4139
+ response.setHeader("Set-Cookie", [
4140
+ cookie(SESSION_COOKIE, result.sessionToken, {
4141
+ tls: this.tlsEnabled,
4142
+ httpOnly: true,
4143
+ path: "/",
4144
+ maxAgeSeconds: maxAge
4145
+ }),
4146
+ cookie(CSRF_COOKIE, result.csrfToken, {
4147
+ tls: this.tlsEnabled,
4148
+ httpOnly: false,
4149
+ path: "/",
4150
+ maxAgeSeconds: maxAge
4151
+ }),
4152
+ ...this.tlsEnabled ? [cookie(HOST_SESSION_COOKIE, result.sessionToken, {
4153
+ tls: true,
4154
+ httpOnly: true,
4155
+ path: "/",
4156
+ maxAgeSeconds: maxAge
4157
+ }), cookie(HOST_CSRF_COOKIE, result.csrfToken, {
4158
+ tls: true,
4159
+ httpOnly: false,
4160
+ path: "/",
4161
+ maxAgeSeconds: maxAge
4162
+ })] : []
4163
+ ]);
4164
+ }
4165
+ setBrowserDeviceBinding(response, deviceId, deviceExpiresAt) {
4166
+ if (!this.tlsEnabled) return;
4167
+ const existing = response.getHeader("Set-Cookie");
4168
+ response.setHeader("Set-Cookie", [...existing, cookie(HOST_DEVICE_COOKIE, deviceId, {
4169
+ tls: true,
3770
4170
  httpOnly: true,
3771
4171
  path: "/",
3772
- maxAgeSeconds: maxAge
3773
- }), cookie(CSRF_COOKIE, result.csrfToken, {
3774
- tls: this.tlsEnabled,
3775
- httpOnly: false,
3776
- path: "/",
3777
- maxAgeSeconds: maxAge
4172
+ maxAgeSeconds: (deviceExpiresAt - Date.now()) / 1e3
3778
4173
  })]);
3779
4174
  }
3780
4175
  async handlePair(request, response) {
@@ -3790,6 +4185,7 @@ var MobileAccessGateway = class {
3790
4185
  path: "/mobile-access/auth/renew",
3791
4186
  maxAgeSeconds: (result.deviceExpiresAt - now) / 1e3
3792
4187
  })]);
4188
+ this.setBrowserDeviceBinding(response, result.deviceId, result.deviceExpiresAt);
3793
4189
  sendJson(response, 201, {
3794
4190
  paired: true,
3795
4191
  deviceId: result.deviceId,
@@ -3800,21 +4196,13 @@ var MobileAccessGateway = class {
3800
4196
  async handleRenew(request, response) {
3801
4197
  if (!this.renewLimiter.take(request.socket.remoteAddress ?? "unknown", Date.now())) throw new HttpError(429, "rate_limited");
3802
4198
  await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
3803
- const deviceToken = requestCookies(request).get(DEVICE_COOKIE);
3804
- if (deviceToken === void 0) throw new HttpError(401, "authentication_failed");
3805
- let result;
3806
- try {
3807
- result = await this.access.renew(deviceToken);
3808
- } catch (error) {
3809
- if (error instanceof AccessError && error.status === 401) response.setHeader("Set-Cookie", cookie(DEVICE_COOKIE, "", {
3810
- tls: this.tlsEnabled,
3811
- httpOnly: true,
3812
- path: "/mobile-access/auth/renew",
3813
- maxAgeSeconds: 0
3814
- }));
3815
- throw error;
3816
- }
4199
+ const cookies = browserAuthCookies(request.headers.cookie);
4200
+ const bindings = this.tlsEnabled ? cookies.get(HOST_DEVICE_COOKIE) : void 0;
4201
+ if (bindings !== void 0 && bindings.length !== 1) throw new HttpError(401, "authentication_failed");
4202
+ const device = authenticatedCookie(cookies.get("dsh_ma_device") ?? [], (token) => this.access.authorizeDevice(token), (candidate) => bindings === void 0 || candidate.deviceId === bindings[0]);
4203
+ const result = await this.access.renew(device.value);
3817
4204
  this.setSessionCookies(response, result, Date.now());
4205
+ this.setBrowserDeviceBinding(response, result.deviceId, device.result.deviceExpiresAt);
3818
4206
  sendJson(response, 200, {
3819
4207
  renewed: true,
3820
4208
  deviceId: result.deviceId,
@@ -3865,17 +4253,31 @@ var MobileAccessGateway = class {
3865
4253
  const authorization = this.authorize(request);
3866
4254
  this.requireCsrf(request, authorization);
3867
4255
  this.access.logout(authorization);
3868
- response.setHeader("Set-Cookie", [cookie(SESSION_COOKIE, "", {
3869
- tls: this.tlsEnabled,
3870
- httpOnly: true,
3871
- path: "/",
3872
- maxAgeSeconds: 0
3873
- }), cookie(CSRF_COOKIE, "", {
3874
- tls: this.tlsEnabled,
3875
- httpOnly: false,
3876
- path: "/",
3877
- maxAgeSeconds: 0
3878
- })]);
4256
+ response.setHeader("Set-Cookie", [
4257
+ cookie(SESSION_COOKIE, "", {
4258
+ tls: this.tlsEnabled,
4259
+ httpOnly: true,
4260
+ path: "/",
4261
+ maxAgeSeconds: 0
4262
+ }),
4263
+ cookie(CSRF_COOKIE, "", {
4264
+ tls: this.tlsEnabled,
4265
+ httpOnly: false,
4266
+ path: "/",
4267
+ maxAgeSeconds: 0
4268
+ }),
4269
+ ...this.tlsEnabled ? [cookie(HOST_SESSION_COOKIE, "", {
4270
+ tls: true,
4271
+ httpOnly: true,
4272
+ path: "/",
4273
+ maxAgeSeconds: 0
4274
+ }), cookie(HOST_CSRF_COOKIE, "", {
4275
+ tls: true,
4276
+ httpOnly: false,
4277
+ path: "/",
4278
+ maxAgeSeconds: 0
4279
+ })] : []
4280
+ ]);
3879
4281
  sendJson(response, 200, { loggedOut: true }, this.tlsEnabled);
3880
4282
  }
3881
4283
  async handleExternalRequest(request, response) {
@@ -3994,7 +4396,14 @@ var MobileAccessGateway = class {
3994
4396
  fallback: void 0
3995
4397
  } : void 0 : void 0;
3996
4398
  if (customAsset === void 0 && requestedMobileBootBatch === void 0 && !computerImages && !computerImage && extensionTarget(target.decodedPathname) === void 0 && (target.decodedPathname === "/mobile-access" || target.decodedPathname.startsWith(`/mobile-access/`))) throw new HttpError(404, "not_found");
3997
- if (request.method !== "GET" && request.method !== "HEAD" && request.method !== "POST" && requestedExtension?.kind !== "route") throw new HttpError(405, "method_not_allowed");
4399
+ if (![
4400
+ "GET",
4401
+ "HEAD",
4402
+ "POST",
4403
+ "PUT",
4404
+ "PATCH",
4405
+ "DELETE"
4406
+ ].includes(request.method ?? "") && requestedExtension?.kind !== "route") throw new HttpError(405, "method_not_allowed");
3998
4407
  let authorization;
3999
4408
  try {
4000
4409
  authorization = this.authorize(request);
@@ -4400,9 +4809,15 @@ var MobileAccessGateway = class {
4400
4809
  try {
4401
4810
  const html = Buffer.concat(chunks).toString("utf8");
4402
4811
  const plan = parseMobileBootPlan(html, this.config.excludedClientModules);
4403
- const rewritten = rewriteMobileIndexWithBatch(html, plan.rewrittenBatchEntries === void 0 ? { excludedClientModules: this.config.excludedClientModules } : {
4812
+ const options = plan.rewrittenBatchEntries === void 0 ? { excludedClientModules: this.config.excludedClientModules } : {
4404
4813
  ...await this.resolveMobileBootSizes(plan.rewrittenBatchEntries.flatMap((batch) => batch.entries)),
4405
4814
  excludedClientModules: this.config.excludedClientModules
4815
+ };
4816
+ const cookies = browserAuthCookies(request$2.headers.cookie);
4817
+ const csrfCookie = this.tlsEnabled && cookies.has("__Host-dsh_ma_session") ? HOST_CSRF_COOKIE : CSRF_COOKIE;
4818
+ const rewritten = rewriteMobileIndexWithBatch(html, {
4819
+ ...options,
4820
+ csrfCookie
4406
4821
  });
4407
4822
  for (const batch of rewritten.batches) this.rememberMobileBootBatch(batch);
4408
4823
  body = Buffer.from(rewritten.html);
@@ -4771,11 +5186,9 @@ var MobileAccessGateway = class {
4771
5186
  const operation = this.allocateRequest(authorization, response, holder);
4772
5187
  let bodyDone;
4773
5188
  try {
4774
- const bufferedBody = request$3.method === "POST" && request$3.url?.split("?", 1)[0] === SESSION_HISTORY_PATH ? mobileHistoryRequestBody(request$3, await readBoundedBody(request$3, this.config.maxBodyBytes)) : void 0;
4775
5189
  const upstreamHeaders = sanitizeRequestHeaders(request$3, this.config.upstreamOrigin);
4776
5190
  const upstreamCookie = await this.upstreamCookieHeader();
4777
5191
  if (upstreamCookie !== void 0) upstreamHeaders.cookie = upstreamCookie;
4778
- if (bufferedBody !== void 0) upstreamHeaders["content-length"] = String(bufferedBody.byteLength);
4779
5192
  const proxied = await new Promise((resolve, reject) => {
4780
5193
  const upstreamRequest = request({
4781
5194
  protocol: "http:",
@@ -4792,11 +5205,7 @@ var MobileAccessGateway = class {
4792
5205
  });
4793
5206
  upstreamRequest.once("response", resolve);
4794
5207
  upstreamRequest.once("error", reject);
4795
- if (bufferedBody === void 0) bodyDone = pipeline(request$3, new ByteLimitTransform(this.config.maxBodyBytes), upstreamRequest);
4796
- else {
4797
- upstreamRequest.end(bufferedBody);
4798
- bodyDone = Promise.resolve();
4799
- }
5208
+ bodyDone = pipeline(request$3, new ByteLimitTransform(this.config.maxBodyBytes), upstreamRequest);
4800
5209
  bodyDone.catch(reject);
4801
5210
  });
4802
5211
  setSecurityHeaders(response, this.tlsEnabled, "proxied");
@@ -5019,10 +5428,13 @@ var MobileAccessGateway = class {
5019
5428
  throw new HttpError(400, "bad_request");
5020
5429
  }
5021
5430
  if (decodedKey.length !== 16 || decodedKey.toString("base64") !== key) throw new HttpError(400, "bad_request");
5022
- const authorization = this.authorize(request);
5431
+ let authorization = this.authorize(request);
5023
5432
  if (this.activeWebSockets.size >= this.config.maxWebSockets) throw new HttpError(429, "busy");
5024
5433
  const upstreamCookie = await this.upstreamCookieHeader();
5025
5434
  if (client.destroyed) return;
5435
+ authorization = this.authorize(request);
5436
+ if (this.activeWebSockets.size >= this.config.maxWebSockets) throw new HttpError(429, "busy");
5437
+ const compressed = this.config.websocketCompression.paths.includes(target.decodedPathname);
5026
5438
  const upstream = connect({
5027
5439
  host: stripIpv6Brackets(this.config.upstreamOrigin.hostname),
5028
5440
  port: Number(this.config.upstreamOrigin.port)
@@ -5045,20 +5457,28 @@ var MobileAccessGateway = class {
5045
5457
  });
5046
5458
  this.activeWebSockets.set(id, record);
5047
5459
  const cleanup = () => {
5460
+ if (compressed && (!client.destroyed || !upstream.destroyed)) return;
5048
5461
  const active = this.activeWebSockets.get(id);
5049
5462
  if (active !== void 0) clearTimeout(active.timer);
5050
5463
  this.activeWebSockets.delete(id);
5051
5464
  };
5052
5465
  client.once("close", () => {
5053
- upstream.destroy();
5466
+ if (!compressed) upstream.destroy();
5054
5467
  cleanup();
5055
5468
  });
5056
5469
  upstream.once("close", () => {
5057
- client.destroy();
5470
+ if (!compressed) client.destroy();
5058
5471
  cleanup();
5059
5472
  });
5060
5473
  upstream.setTimeout(this.config.upstreamTimeoutMs, closeBoth);
5061
5474
  try {
5475
+ if (compressed) {
5476
+ const compressionTarget = new URL(this.config.upstreamOrigin.href);
5477
+ compressionTarget.pathname = target.pathname;
5478
+ compressionTarget.search = target.search;
5479
+ await bridgeCompressedWebSocket(request, client, upstream, head, compressionTarget, upstreamCookie, this.config.websocketCompression, this.config.upstreamTimeoutMs);
5480
+ return;
5481
+ }
5062
5482
  await new Promise((resolve, reject) => {
5063
5483
  const connected = () => {
5064
5484
  upstream.off("error", failed);
@@ -5698,112 +6118,6 @@ var FrpComponentManager = class {
5698
6118
  }
5699
6119
  };
5700
6120
  //#endregion
5701
- //#region src/websocket-paths.ts
5702
- /** Cap the admin-approved extra WebSocket upgrade paths (exact pathnames). */
5703
- const MAX_EXTRA_WEBSOCKET_PATHS = 16;
5704
- const MAX_WEBSOCKET_PATH_LENGTH = 256;
5705
- function fail(code) {
5706
- throw new Error(code);
5707
- }
5708
- /**
5709
- * Validate one exact pathname for proxying. Query strings are matched at
5710
- * upgrade time, so only the pathname is stored. Rejects anything the
5711
- * gateway cannot match exactly.
5712
- */
5713
- function validateWebSocketPath(value) {
5714
- if (typeof value !== "string") fail("websocket_path_invalid");
5715
- const path = value;
5716
- if (path.length === 0 || path.length > 256) fail("websocket_path_invalid");
5717
- if (!path.startsWith("/")) fail("websocket_path_invalid");
5718
- if (/[\s\u0000-\u001f\u007f#?]/u.test(path)) fail("websocket_path_invalid");
5719
- if (path.includes("..")) fail("websocket_path_invalid");
5720
- return path;
5721
- }
5722
- /** Validate a whole replacement list all-or-nothing; duplicates collapse. */
5723
- function normalizeWebSocketPaths(value) {
5724
- if (!Array.isArray(value)) fail("websocket_paths_invalid");
5725
- if (value.length > 16) fail("websocket_paths_invalid");
5726
- const seen = /* @__PURE__ */ new Set();
5727
- for (const entry of value) {
5728
- const path = validateWebSocketPath(entry);
5729
- seen.add(path);
5730
- }
5731
- return [...seen];
5732
- }
5733
- /** Cap remembered rejected upgrade paths offered for one-click approval. */
5734
- const MAX_BLOCKED_UPGRADE_PATHS = 32;
5735
- /**
5736
- * In-memory log of rejected third-party upgrade paths, shared by every
5737
- * gateway instance so the approval UI sees attempts on any listener.
5738
- * Bounded and newest-first; a restart clears it (attempts reappear on
5739
- * the next blocked handshake).
5740
- */
5741
- var BlockedUpgradePathLog = class {
5742
- entries = /* @__PURE__ */ new Map();
5743
- record(pathname) {
5744
- const now = Date.now();
5745
- const stats = this.entries.get(pathname);
5746
- if (stats === void 0) {
5747
- if (this.entries.size >= 32) {
5748
- let oldest;
5749
- for (const [path, entry] of this.entries) if (oldest === void 0 || entry.lastSeen < (this.entries.get(oldest)?.lastSeen ?? 0)) oldest = path;
5750
- if (oldest !== void 0) this.entries.delete(oldest);
5751
- }
5752
- this.entries.set(pathname, {
5753
- attempts: 1,
5754
- firstSeen: now,
5755
- lastSeen: now
5756
- });
5757
- return;
5758
- }
5759
- stats.attempts += 1;
5760
- stats.lastSeen = now;
5761
- }
5762
- report() {
5763
- return [...this.entries].map(([path, stats]) => ({
5764
- path,
5765
- ...stats
5766
- })).sort((a, b) => b.lastSeen - a.lastSeen);
5767
- }
5768
- };
5769
- /** File-backed store shared by every gateway instance (LAN and remote). */
5770
- var WebSocketPathStore = class {
5771
- file;
5772
- paths = /* @__PURE__ */ new Set();
5773
- loaded = false;
5774
- constructor(file) {
5775
- this.file = file;
5776
- }
5777
- /** Snapshot for the upgrade check. */
5778
- has(pathname) {
5779
- return this.paths.has(pathname);
5780
- }
5781
- list() {
5782
- return [...this.paths];
5783
- }
5784
- async load() {
5785
- if (!this.loaded) {
5786
- try {
5787
- const raw = JSON.parse(await readFile(this.file, "utf8"));
5788
- this.paths = new Set(normalizeWebSocketPaths(raw.paths ?? []));
5789
- } catch {
5790
- this.paths = /* @__PURE__ */ new Set();
5791
- }
5792
- this.loaded = true;
5793
- }
5794
- return this.list();
5795
- }
5796
- /** Replace the whole list after validating; persists atomically. */
5797
- async replace(paths) {
5798
- const next = normalizeWebSocketPaths([...paths]);
5799
- await mkdir(dirname(this.file), { recursive: true });
5800
- await writeFile(this.file, `${JSON.stringify({ paths: next })}\n`, "utf8");
5801
- this.paths = new Set(next);
5802
- this.loaded = true;
5803
- return this.list();
5804
- }
5805
- };
5806
- //#endregion
5807
6121
  //#region src/frp-template.ts
5808
6122
  /** Loopback-only HTTP vhost port used between Caddy and frps. */
5809
6123
  const FRP_VHOST_HTTP_PORT = 7080;