dsh-mobile 0.6.14 → 0.6.16

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
@@ -17,6 +17,7 @@ import { promisify } from "node:util";
17
17
  import { createGzip, gzip } from "node:zlib";
18
18
  import Bonjour from "bonjour-service";
19
19
  import * as QRCode from "qrcode";
20
+ import { createParser } from "eventsource-parser";
20
21
  import WebSocket, { WebSocketServer } from "ws";
21
22
  import { Logger, Service } from "@deepseek-ai/cordis";
22
23
  import { Worker } from "node:worker_threads";
@@ -1810,6 +1811,7 @@ function setSecurityHeaders(response, tls, framing = "gateway") {
1810
1811
  "font-src 'self' data:",
1811
1812
  "connect-src 'self'",
1812
1813
  "worker-src 'self' blob:",
1814
+ ...proxied ? ["media-src 'self' blob:"] : [],
1813
1815
  ...proxied ? [PROXIED_FRAME_SRC] : []
1814
1816
  ].join("; "));
1815
1817
  const microphone = proxied ? "microphone=(self)" : "microphone=()";
@@ -1899,6 +1901,7 @@ function assertLocalAdminTrust(request, requireBrowserOrigin, authenticateDeskto
1899
1901
  if (request.socket.remoteAddress === void 0 || !isLoopbackAddress(request.socket.remoteAddress)) throw new HttpError(403, "forbidden");
1900
1902
  const host = localAuthority(request.headers.host);
1901
1903
  if (host === void 0 || !isLocalAdminHostname(host.hostname)) throw new HttpError(403, "forbidden");
1904
+ if (request.method === "OPTIONS") throw new HttpError(403, "forbidden");
1902
1905
  const site = request.headers["sec-fetch-site"];
1903
1906
  if (site !== void 0 && site !== "same-origin" && site !== "none") throw new HttpError(403, "forbidden");
1904
1907
  const origin = request.headers.origin;
@@ -1910,7 +1913,9 @@ function assertLocalAdminTrust(request, requireBrowserOrigin, authenticateDeskto
1910
1913
  throw new HttpError(403, "forbidden");
1911
1914
  }
1912
1915
  const desktopForward = site === void 0 && request.headers["x-dsh-mobile-desktop-admin"] === "dsh-app://app";
1913
- if (requireBrowserOrigin && origin === void 0 && !(desktopForward && authenticateDesktop())) throw new HttpError(403, "forbidden");
1916
+ const contentType = request.headers["content-type"];
1917
+ const webRequest = request.method === "POST" && (site === void 0 || site === "same-origin") && request.headers["x-dsh-mobile-web-admin"] === "same-origin-json" && request.headers["x-dsh-mobile-desktop-admin"] === void 0 && typeof contentType === "string" && contentType.split(";", 1)[0]?.trim().toLowerCase() === "application/json";
1918
+ if (requireBrowserOrigin && origin === void 0 && !webRequest && !(desktopForward && authenticateDesktop())) throw new HttpError(403, "forbidden");
1914
1919
  if (requireBrowserOrigin && site !== void 0 && site !== "same-origin") throw new HttpError(403, "forbidden");
1915
1920
  }
1916
1921
  //#endregion
@@ -1968,7 +1973,7 @@ function authenticatedCookie(values, authenticate, accepts = () => true) {
1968
1973
  }
1969
1974
  //#endregion
1970
1975
  //#region src/auth-failure-log.ts
1971
- const LOG_INTERVAL_MS$1 = 3e4;
1976
+ const LOG_INTERVAL_MS$2 = 3e4;
1972
1977
  const MAX_LOGGED_ORIGIN_LENGTH = 512;
1973
1978
  function requestPathCategory(path) {
1974
1979
  switch (path) {
@@ -2065,7 +2070,7 @@ var AuthFailureRecorder = class {
2065
2070
  return;
2066
2071
  }
2067
2072
  const suppressed = window.suppressed;
2068
- window.nextLogAt = now + LOG_INTERVAL_MS$1;
2073
+ window.nextLogAt = now + LOG_INTERVAL_MS$2;
2069
2074
  window.suppressed = 0;
2070
2075
  try {
2071
2076
  this.observe({
@@ -2119,7 +2124,7 @@ function classifyHttpParserFailure(nodeCode) {
2119
2124
  parserCode
2120
2125
  };
2121
2126
  }
2122
- const LOG_INTERVAL_MS = 3e4;
2127
+ const LOG_INTERVAL_MS$1 = 3e4;
2123
2128
  /**
2124
2129
  * Per-Gateway diagnostics with three fixed sampling slots and no timers or request-selected keys.
2125
2130
  * Each kind records once immediately, then at most once per 30 seconds with suppressed counts.
@@ -2188,7 +2193,7 @@ var HttpFailureRecorder = class {
2188
2193
  return;
2189
2194
  }
2190
2195
  const suppressed = window.suppressed;
2191
- window.nextLogAt = now + LOG_INTERVAL_MS;
2196
+ window.nextLogAt = now + LOG_INTERVAL_MS$1;
2192
2197
  window.suppressed = 0;
2193
2198
  try {
2194
2199
  this.observe({
@@ -2205,6 +2210,72 @@ var HttpFailureRecorder = class {
2205
2210
  }
2206
2211
  };
2207
2212
  //#endregion
2213
+ //#region src/upstream-auth-log.ts
2214
+ const LOG_INTERVAL_MS = 3e4;
2215
+ /** Two fixed sampling slots keep a first failure visible even after a successful exchange. */
2216
+ var UpstreamAuthRecorder = class {
2217
+ observe;
2218
+ now;
2219
+ reportObserverFailure;
2220
+ sequence = 0;
2221
+ windows = {
2222
+ success: {
2223
+ nextLogAt: 0,
2224
+ suppressed: 0
2225
+ },
2226
+ failure: {
2227
+ nextLogAt: 0,
2228
+ suppressed: 0
2229
+ }
2230
+ };
2231
+ observerFailureReported = false;
2232
+ constructor(observe, now = () => performance.now(), reportObserverFailure = () => {
2233
+ process.emitWarning("Mobile access upstream authentication diagnostics could not be recorded", { code: "DSH_MOBILE_UPSTREAM_AUTH_LOG_FAILED" });
2234
+ }) {
2235
+ this.observe = observe;
2236
+ this.now = now;
2237
+ this.reportObserverFailure = reportObserverFailure;
2238
+ }
2239
+ /** Begin only a physical exchange, not every consumer sharing its promise or cached Cookie. */
2240
+ begin() {
2241
+ if (this.observe === void 0) return void 0;
2242
+ this.sequence = this.sequence >= Number.MAX_SAFE_INTEGER ? 1 : this.sequence + 1;
2243
+ return {
2244
+ sequence: this.sequence,
2245
+ startedAt: this.now()
2246
+ };
2247
+ }
2248
+ /** Record the completed exchange without changing authentication, retrying it or retaining its inputs. */
2249
+ record(attempt, result) {
2250
+ if (attempt === void 0 || this.observe === void 0) return;
2251
+ const now = this.now();
2252
+ const window = this.windows[result.outcome === "accepted" ? "success" : "failure"];
2253
+ if (now < window.nextLogAt) {
2254
+ window.suppressed = Math.min(Number.MAX_SAFE_INTEGER, window.suppressed + 1);
2255
+ return;
2256
+ }
2257
+ const suppressed = window.suppressed;
2258
+ window.nextLogAt = now + LOG_INTERVAL_MS;
2259
+ window.suppressed = 0;
2260
+ try {
2261
+ this.observe({
2262
+ sequence: attempt.sequence,
2263
+ outcome: result.outcome,
2264
+ stage: result.stage,
2265
+ durationMs: Math.max(0, Math.floor(now - attempt.startedAt)),
2266
+ ...result.status === void 0 ? {} : { status: result.status },
2267
+ ...suppressed === 0 ? {} : { suppressed }
2268
+ });
2269
+ } catch (observerError) {
2270
+ if (this.observerFailureReported) return;
2271
+ this.observerFailureReported = true;
2272
+ try {
2273
+ this.reportObserverFailure();
2274
+ } catch (reporterError) {}
2275
+ }
2276
+ }
2277
+ };
2278
+ //#endregion
2208
2279
  //#region src/client-module-recovery.ts
2209
2280
  const CLIENT_MODULE_RECOVERY_COPY = {
2210
2281
  zh: {
@@ -2384,6 +2455,196 @@ function weakenEntityTag(etag) {
2384
2455
  return opaque === void 0 ? void 0 : `W/${opaque}`;
2385
2456
  }
2386
2457
  //#endregion
2458
+ //#region src/mobile-graph-events.ts
2459
+ /** Bounded SSE framing for the dedicated Mobile client graph stream. */
2460
+ /** Maximum retained UTF-8 frame/output bytes, with CRLF normalized to LF. */
2461
+ const MOBILE_GRAPH_EVENT_MAX_BYTES = 4194304;
2462
+ const LINE_FEED = Buffer.from("\n");
2463
+ /** Fixed failures contain neither an upstream payload nor a projection exception. */
2464
+ var MobileGraphEventError = class extends Error {
2465
+ code;
2466
+ constructor(code) {
2467
+ super(`Mobile graph event stream: ${code}`);
2468
+ this.code = code;
2469
+ this.name = "MobileGraphEventError";
2470
+ }
2471
+ };
2472
+ var MobileGraphEventTransform = class extends Transform {
2473
+ project;
2474
+ frame = Buffer.allocUnsafe(16384);
2475
+ used = 0;
2476
+ lineBytes = 0;
2477
+ skipLf = false;
2478
+ firstFrame = true;
2479
+ disposed = false;
2480
+ readWait;
2481
+ constructor(project) {
2482
+ super();
2483
+ this.project = project;
2484
+ }
2485
+ _transform(chunk, _encoding, callback) {
2486
+ this.consume(chunk).then(() => callback(), (error) => callback(error));
2487
+ }
2488
+ _read(size) {
2489
+ super._read(size);
2490
+ const waiting = this.readWait;
2491
+ this.readWait = void 0;
2492
+ waiting?.resolve();
2493
+ }
2494
+ _flush(callback) {
2495
+ if (this.used !== 0) {
2496
+ try {
2497
+ this.decodeFrame();
2498
+ } catch {
2499
+ callback(new MobileGraphEventError("invalid_utf8"));
2500
+ return;
2501
+ }
2502
+ callback(new MobileGraphEventError("incomplete_frame"));
2503
+ return;
2504
+ }
2505
+ callback();
2506
+ }
2507
+ _destroy(error, callback) {
2508
+ this.disposed = true;
2509
+ const waiting = this.readWait;
2510
+ this.readWait = void 0;
2511
+ waiting?.reject(error ?? new MobileGraphEventError("stream_closed"));
2512
+ this.frame = Buffer.alloc(0);
2513
+ this.used = 0;
2514
+ callback(error);
2515
+ }
2516
+ requireOpen() {
2517
+ if (this.disposed || this.destroyed) throw new MobileGraphEventError("stream_closed");
2518
+ }
2519
+ append(bytes) {
2520
+ const required = this.used + bytes.length;
2521
+ if (required > 4194304) throw new MobileGraphEventError("frame_too_large");
2522
+ if (required > this.frame.length) {
2523
+ const next = Buffer.allocUnsafe(Math.min(MOBILE_GRAPH_EVENT_MAX_BYTES, Math.max(required, this.frame.length * 2)));
2524
+ this.frame.copy(next, 0, 0, this.used);
2525
+ this.frame = next;
2526
+ }
2527
+ bytes.copy(this.frame, this.used);
2528
+ this.used = required;
2529
+ }
2530
+ async consume(chunk) {
2531
+ let offset = 0;
2532
+ while (offset < chunk.length) {
2533
+ this.requireOpen();
2534
+ if (this.skipLf) {
2535
+ this.skipLf = false;
2536
+ if (chunk[offset] === 10) {
2537
+ offset += 1;
2538
+ continue;
2539
+ }
2540
+ }
2541
+ let contentEnd = offset;
2542
+ while (contentEnd < chunk.length && chunk[contentEnd] !== 13 && chunk[contentEnd] !== 10) contentEnd += 1;
2543
+ const content = chunk.subarray(offset, contentEnd);
2544
+ this.append(content);
2545
+ this.lineBytes += content.length;
2546
+ if (contentEnd === chunk.length) return;
2547
+ this.append(LINE_FEED);
2548
+ this.skipLf = chunk[contentEnd] === 13;
2549
+ const emptyLine = this.lineBytes === 0;
2550
+ this.lineBytes = 0;
2551
+ offset = contentEnd + 1;
2552
+ if (emptyLine) await this.completeFrame();
2553
+ }
2554
+ }
2555
+ decodeFrame() {
2556
+ try {
2557
+ return new TextDecoder("utf-8", {
2558
+ fatal: true,
2559
+ ignoreBOM: true
2560
+ }).decode(this.frame.subarray(0, this.used));
2561
+ } catch {
2562
+ throw new MobileGraphEventError("invalid_utf8");
2563
+ }
2564
+ }
2565
+ async completeFrame() {
2566
+ let text = this.decodeFrame();
2567
+ this.used = 0;
2568
+ if (this.firstFrame) {
2569
+ this.firstFrame = false;
2570
+ if (text.startsWith("")) text = text.slice(1);
2571
+ }
2572
+ let event;
2573
+ createParser({
2574
+ maxBufferSize: MOBILE_GRAPH_EVENT_MAX_BYTES,
2575
+ onEvent: (value) => {
2576
+ event = value;
2577
+ },
2578
+ onError: () => {
2579
+ throw new MobileGraphEventError("invalid_sse");
2580
+ }
2581
+ }).feed(text);
2582
+ const lines = text.split("\n");
2583
+ for (const line of lines) {
2584
+ if (!line.startsWith("event:")) continue;
2585
+ const type = line.slice(line[6] === " " ? 7 : 6);
2586
+ if (type !== "" && type !== "message") throw new MobileGraphEventError("unsupported_event");
2587
+ }
2588
+ if (event === void 0) {
2589
+ await this.forward(Buffer.from(text, "utf8"));
2590
+ return;
2591
+ }
2592
+ if (event.event !== void 0 && event.event !== "message") throw new MobileGraphEventError("unsupported_event");
2593
+ let value;
2594
+ try {
2595
+ value = JSON.parse(event.data);
2596
+ } catch {
2597
+ throw new MobileGraphEventError("invalid_json");
2598
+ }
2599
+ let projected;
2600
+ try {
2601
+ projected = await this.project(value);
2602
+ } catch {
2603
+ throw new MobileGraphEventError("projection_failed");
2604
+ }
2605
+ this.requireOpen();
2606
+ if (projected === void 0) return;
2607
+ let encoded;
2608
+ try {
2609
+ encoded = JSON.stringify(projected);
2610
+ } catch {
2611
+ throw new MobileGraphEventError("invalid_projection");
2612
+ }
2613
+ if (encoded === void 0) throw new MobileGraphEventError("invalid_projection");
2614
+ const metadata = lines.filter((line) => line !== "" && line !== "data" && !line.startsWith("data:"));
2615
+ const result = `${metadata.length === 0 ? "" : `${metadata.join("\n")}\n`}data: ${encoded}\n\n`;
2616
+ if (Buffer.byteLength(result) > 4194304) throw new MobileGraphEventError("frame_too_large");
2617
+ await this.forward(Buffer.from(result, "utf8"));
2618
+ }
2619
+ async forward(frame) {
2620
+ this.requireOpen();
2621
+ if (frame.length === 0 || this.push(frame)) return;
2622
+ await new Promise((resolve, reject) => {
2623
+ this.readWait = {
2624
+ resolve,
2625
+ reject
2626
+ };
2627
+ if (this.destroyed || this.disposed) {
2628
+ this.readWait = void 0;
2629
+ reject(new MobileGraphEventError("stream_closed"));
2630
+ } else if (this.readableLength < this.readableHighWaterMark) {
2631
+ this.readWait = void 0;
2632
+ resolve();
2633
+ }
2634
+ });
2635
+ }
2636
+ };
2637
+ /**
2638
+ * Project each complete JSON message serially without forwarding unprojected data.
2639
+ * Comments and valid SSE metadata are retained; only the default message event is supported.
2640
+ * Undefined suppresses that message. Malformed, oversized or truncated input terminates the stream.
2641
+ * @param project - Projects one message; caller-owned cancellation must stop any external work.
2642
+ * @returns A byte-mode Transform that honors downstream backpressure and fences late results after destroy.
2643
+ */
2644
+ function createMobileGraphEventTransform(project) {
2645
+ return new MobileGraphEventTransform(project);
2646
+ }
2647
+ //#endregion
2387
2648
  //#region src/websocket-compression.ts
2388
2649
  function payloadBytes(data) {
2389
2650
  if (Array.isArray(data)) return data.reduce((bytes, part) => bytes + part.byteLength, 0);
@@ -4650,6 +4911,26 @@ function renderLoginScript(locale) {
4650
4911
  `;
4651
4912
  }
4652
4913
  //#endregion
4914
+ //#region src/browser-ui-locale.ts
4915
+ const UI_LOCALE_COOKIE = "dsh_ma_ui_locale";
4916
+ /** Read presentation data only; malformed or ambiguous cookies keep browser negotiation. */
4917
+ function readBrowserUiLocale(cookieHeader) {
4918
+ if (cookieHeader === void 0 || cookieHeader.length > 8192) return void 0;
4919
+ let locale;
4920
+ for (const part of cookieHeader.split(";")) {
4921
+ const separator = part.indexOf("=");
4922
+ if ((separator === -1 ? part : part.slice(0, separator)).trim() !== UI_LOCALE_COOKIE) continue;
4923
+ const value = separator === -1 ? "" : part.slice(separator + 1).trim();
4924
+ if (locale !== void 0 || value !== "en" && value !== "zh" && value !== "it") return void 0;
4925
+ locale = value;
4926
+ }
4927
+ return locale;
4928
+ }
4929
+ /** Prefer the last DSH display language over the browser's default language. */
4930
+ function resolveBrowserUiLocale(cookieHeader, acceptLanguage) {
4931
+ return readBrowserUiLocale(cookieHeader) ?? resolveAuthPageLocale(acceptLanguage);
4932
+ }
4933
+ //#endregion
4653
4934
  //#region src/gateway.ts
4654
4935
  const MAX_CONTROL_BODY_BYTES = 16384;
4655
4936
  const MAX_HEADER_BYTES = 16384;
@@ -4660,6 +4941,7 @@ const MDNS_SERVICE_TYPE = "dsh-mobile";
4660
4941
  const MOBILE_LAYOUT_MODULE = "@deepseek-ai/dsh-client-ui-layout";
4661
4942
  const MOBILE_LAYOUT_PATH = `${AUTH_PREFIX}/mobile-layout.js`;
4662
4943
  const MOBILE_BOOT_BATCH_PREFIX = `${AUTH_PREFIX}/mobile-boot/`;
4944
+ const MOBILE_DOCUMENT_VIEW_HEADER = "x-dsh-mobile-view";
4663
4945
  const MAX_MOBILE_BOOT_BATCH_BYTES = 33554432;
4664
4946
  const MAX_MOBILE_BOOT_ENTRY_BYTES = 8388608;
4665
4947
  /**
@@ -4741,7 +5023,9 @@ const MOBILE_LAYOUT_DEPENDENCY_PROFILES = Object.freeze([Object.freeze({
4741
5023
  function mobileCsrfFetchBootstrap(cookieName) {
4742
5024
  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})};})();`;
4743
5025
  }
4744
- 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)}})();`;
5026
+ function mobileAuthenticatedTransportBootstrap(viewId) {
5027
+ return `(()=>{if(window.__DSH_TRANSPORT__!==undefined)throw new Error('DSH Mobile cannot replace an existing transport override');window.__DSH_TRANSPORT__={fetch:${viewId === void 0 ? "(input,init)=>window.fetch(input,init)" : `(input,init)=>{const source=input instanceof Request?input:undefined;const method=String(init?.method??source?.method??'GET').toUpperCase();const raw=typeof input==='string'?input:input instanceof URL?input.href:source?.url;if(raw!==undefined&&method==='GET'){const target=new URL(raw,location.href);if(target.origin===location.origin&&target.pathname==='/plugins/events'){const headers=new Headers(init?.headers??source?.headers);headers.set(${JSON.stringify(MOBILE_DOCUMENT_VIEW_HEADER)},${JSON.stringify(viewId)});return window.fetch(input,{...init,headers})}}return window.fetch(input,init)}`},ownsHost:true},window.__DSH_FILE_UPLOAD__={fetch:(input,init)=>window.fetch(input,init)}})();`;
5028
+ }
4745
5029
  /** Use this page's gateway health, rather than OS internet validation, for DSH reconnects. */
4746
5030
  const MOBILE_GATEWAY_REACHABILITY_BOOTSTRAP = `(()=>{
4747
5031
  const view=globalThis;
@@ -4911,6 +5195,14 @@ function parseMobileBootPlan(html, excludedClientModules = []) {
4911
5195
  const scriptEnd = html.indexOf("<\/script>", valueStart);
4912
5196
  if (scriptEnd < 0) throw new Error("upstream DSH boot manifest script is incomplete");
4913
5197
  const source = html.slice(valueStart, scriptEnd).trim().replace(/;$/u, "");
5198
+ return Object.freeze({
5199
+ ...parseMobileBootGraph(source, excludedClientModules),
5200
+ assignment: assignment[0],
5201
+ replaceStart,
5202
+ replaceEnd: scriptEnd
5203
+ });
5204
+ }
5205
+ function parseMobileBootGraph(source, excludedClientModules) {
4914
5206
  const parsed = JSON.parse(source);
4915
5207
  if (typeof parsed.rev !== "string" || !Array.isArray(parsed.entries)) throw new Error("upstream DSH boot manifest is malformed");
4916
5208
  const entries = parsed.entries;
@@ -4927,10 +5219,7 @@ function parseMobileBootPlan(html, excludedClientModules = []) {
4927
5219
  return Object.freeze({
4928
5220
  parsed,
4929
5221
  entries,
4930
- slotProvider: dependencyProfile.slots,
4931
- assignment: assignment[0],
4932
- replaceStart,
4933
- replaceEnd: scriptEnd
5222
+ slotProvider: dependencyProfile.slots
4934
5223
  });
4935
5224
  }
4936
5225
  if (!Array.isArray(parsedBatches)) throw new Error("upstream DSH boot manifest batches are malformed");
@@ -4969,9 +5258,6 @@ function parseMobileBootPlan(html, excludedClientModules = []) {
4969
5258
  entries,
4970
5259
  batches,
4971
5260
  slotProvider: dependencyProfile.slots,
4972
- assignment: assignment[0],
4973
- replaceStart,
4974
- replaceEnd: scriptEnd,
4975
5261
  layoutBatch,
4976
5262
  planEntries,
4977
5263
  originalApplicationBatches,
@@ -5055,6 +5341,17 @@ function rewriteMobileApplicationPreloads(html, replacements) {
5055
5341
  }
5056
5342
  function rewriteMobileIndexWithBatch(html, options = {}) {
5057
5343
  const plan = parseMobileBootPlan(html, options.excludedClientModules);
5344
+ const projected = projectMobileBootGraph(plan, options);
5345
+ const transportBootstrap = projected.graphTransport ? mobileAuthenticatedTransportBootstrap(options.viewId) : "";
5346
+ const replacement = `${MOBILE_GATEWAY_REACHABILITY_BOOTSTRAP}${transportBootstrap}${mobileCsrfFetchBootstrap(options.csrfCookie ?? "dsh_ma_csrf")}window.__DSH_MOBILE_FRONTEND__="dedicated";${plan.assignment}${JSON.stringify(plan.parsed)};`;
5347
+ const rewritten = `${html.slice(0, plan.replaceStart)}${replacement}${html.slice(plan.replaceEnd)}`;
5348
+ return Object.freeze({
5349
+ html: ensureMobileGatewayMarker(ensureMobileViewport(ensureMobileCompatibility(rewriteMobileApplicationPreloads(rewritten, projected.preloadReplacements)))),
5350
+ batches: projected.batches,
5351
+ graphTransport: projected.graphTransport
5352
+ });
5353
+ }
5354
+ function projectMobileBootGraph(plan, options) {
5058
5355
  const remoteSettings = orderAuthenticatedSettings(plan.entries, plan.slotProvider);
5059
5356
  let batches = [];
5060
5357
  let preloadReplacements = /* @__PURE__ */ new Map();
@@ -5080,11 +5377,10 @@ function rewriteMobileIndexWithBatch(html, options = {}) {
5080
5377
  batches: plan.batches
5081
5378
  })).digest("hex").slice(0, 16);
5082
5379
  }
5083
- 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)};`;
5084
- const rewritten = `${html.slice(0, plan.replaceStart)}${replacement}${html.slice(plan.replaceEnd)}`;
5085
5380
  return Object.freeze({
5086
- html: ensureMobileGatewayMarker(ensureMobileViewport(ensureMobileCompatibility(rewriteMobileApplicationPreloads(rewritten, preloadReplacements)))),
5087
- batches
5381
+ batches,
5382
+ preloadReplacements,
5383
+ graphTransport: remoteSettings
5088
5384
  });
5089
5385
  }
5090
5386
  /** Rewrite the dedicated mobile graph; exclusions are opt-in and leave stock DSH unchanged. */
@@ -5515,6 +5811,8 @@ var MobileAccessGateway = class {
5515
5811
  activeRequests = /* @__PURE__ */ new Map();
5516
5812
  activeWebSockets = /* @__PURE__ */ new Map();
5517
5813
  mobileBootBatches = /* @__PURE__ */ new Map();
5814
+ mobileDocumentViews = /* @__PURE__ */ new Map();
5815
+ mobileGraphTasks = /* @__PURE__ */ new Set();
5518
5816
  /** Cached upstream bundle byte sizes (HEAD probes), keyed by resource URL. */
5519
5817
  bootEntrySizeCache = /* @__PURE__ */ new Map();
5520
5818
  extensionEventListeners = /* @__PURE__ */ new Set();
@@ -5547,7 +5845,8 @@ var MobileAccessGateway = class {
5547
5845
  clientModuleRevocations = /* @__PURE__ */ new Set();
5548
5846
  authFailures;
5549
5847
  httpFailures;
5550
- constructor(config, store, extensions, upstreamAuthenticatedUrl, extraWebSocketPaths, blockedUpgradeLog, onDiscoveryDegraded, clientModulePreferences, authFailureLog, httpFailureLog) {
5848
+ upstreamAuthDiagnostics;
5849
+ constructor(config, store, extensions, upstreamAuthenticatedUrl, extraWebSocketPaths, blockedUpgradeLog, onDiscoveryDegraded, clientModulePreferences, authFailureLog, httpFailureLog, upstreamAuthLog) {
5551
5850
  this.config = config;
5552
5851
  this.extensions = extensions;
5553
5852
  this.upstreamAuthenticatedUrl = upstreamAuthenticatedUrl;
@@ -5556,6 +5855,7 @@ var MobileAccessGateway = class {
5556
5855
  this.onDiscoveryDegraded = onDiscoveryDegraded;
5557
5856
  this.authFailures = new AuthFailureRecorder(authFailureLog);
5558
5857
  this.httpFailures = new HttpFailureRecorder(httpFailureLog);
5858
+ this.upstreamAuthDiagnostics = new UpstreamAuthRecorder(upstreamAuthLog);
5559
5859
  this.clientModulePreferences = clientModulePreferences ?? new ClientModulePreferenceStore(join(dirname(config.stateFile), `${basename(config.stateFile)}.client-modules.json`), config.excludedClientModules);
5560
5860
  this.listenerTlsEnabled = config.tls.mode === "provided";
5561
5861
  this.tlsEnabled = config.publicTls;
@@ -5578,6 +5878,7 @@ var MobileAccessGateway = class {
5578
5878
  this.probeFailureLimiter = new BoundedRateLimiter(probeLimit, config.rateLimitWindowMs, config.maxRateLimitKeys);
5579
5879
  this.probeDeviceLimiter = new BoundedRateLimiter(probeLimit, config.rateLimitWindowMs, config.maxDevices);
5580
5880
  this.removeSessionListener = this.access.onSessionEnded((authorization, reason) => {
5881
+ for (const view of this.mobileDocumentViews.values()) if (view.sessionKey === authorization.sessionKey) this.removeMobileDocumentView(view);
5581
5882
  if (reason === "revoked") {
5582
5883
  const cleanup = this.clientModulePreferences.removeDevice(authorization.deviceId).catch(() => {
5583
5884
  process.emitWarning("Unable to remove revoked device client preferences", { code: "DSH_MOBILE_PREFERENCE_CLEANUP_FAILED" });
@@ -5976,12 +6277,17 @@ var MobileAccessGateway = class {
5976
6277
  nativeResponseInstanceId(result) {
5977
6278
  return this.config.pairingCaFile === void 0 ? result.nativeInstanceId ?? this.config.instanceId : this.config.instanceId;
5978
6279
  }
6280
+ /** Older Apps require exact wire keys; expose this hint only to explicit opt-in clients after authentication. */
6281
+ nativeResponseInstallationIdentity(request) {
6282
+ return this.config.installationId === void 0 || request.headers["x-dsh-mobile-capabilities"] !== "installation-id" ? {} : { installationId: this.config.installationId };
6283
+ }
5979
6284
  async handleNativePair(request, response) {
5980
6285
  const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
5981
6286
  if (typeof body.token !== "string" || body.label !== void 0 && typeof body.label !== "string") throw new HttpError(400, "bad_request");
5982
6287
  const result = await this.access.pair(request.socket.remoteAddress ?? "unknown", body.token, body.label);
5983
6288
  sendJson(response, 201, {
5984
6289
  instanceId: this.nativeResponseInstanceId(result),
6290
+ ...this.nativeResponseInstallationIdentity(request),
5985
6291
  deviceId: result.deviceId,
5986
6292
  deviceToken: result.deviceToken,
5987
6293
  deviceExpiresAt: result.deviceExpiresAt,
@@ -6002,6 +6308,7 @@ var MobileAccessGateway = class {
6002
6308
  const result = await this.access.renew(selected.token);
6003
6309
  sendJson(response, 200, {
6004
6310
  instanceId: this.nativeResponseInstanceId(result),
6311
+ ...this.nativeResponseInstallationIdentity(request),
6005
6312
  deviceId: result.deviceId,
6006
6313
  sessionToken: result.sessionToken,
6007
6314
  csrfToken: result.csrfToken,
@@ -6021,6 +6328,7 @@ var MobileAccessGateway = class {
6021
6328
  const result = await this.access.probe(selected.token);
6022
6329
  sendJson(response, 200, {
6023
6330
  instanceId: this.nativeResponseInstanceId(result),
6331
+ ...this.nativeResponseInstallationIdentity(request),
6024
6332
  deviceId: result.deviceId,
6025
6333
  deviceExpiresAt: result.deviceExpiresAt
6026
6334
  }, this.tlsEnabled);
@@ -6109,9 +6417,9 @@ var MobileAccessGateway = class {
6109
6417
  if (target.search === "" && request.method === "GET" && (target.decodedPathname === `/mobile-access/pair` || target.decodedPathname === `/mobile-access/pair.js`)) {
6110
6418
  if (!this.access.pairingStatus().open) throw new HttpError(404, "not_found");
6111
6419
  const languageHeader = request.headers["accept-language"];
6112
- const locale = resolveAuthPageLocale(typeof languageHeader === "string" ? languageHeader : void 0);
6420
+ const locale = resolveBrowserUiLocale(request.headers.cookie, typeof languageHeader === "string" ? languageHeader : void 0);
6113
6421
  setSecurityHeaders(response, this.tlsEnabled);
6114
- response.setHeader("Vary", "Accept-Language");
6422
+ response.setHeader("Vary", "Accept-Language, Cookie");
6115
6423
  const body = target.decodedPathname.endsWith(".js") ? renderPairScript(locale) : renderPairPage(locale);
6116
6424
  response.writeHead(200, {
6117
6425
  "Content-Type": target.decodedPathname.endsWith(".js") ? "text/javascript; charset=utf-8" : "text/html; charset=utf-8",
@@ -6123,9 +6431,9 @@ var MobileAccessGateway = class {
6123
6431
  if (request.method === "GET" && (target.decodedPathname === `/mobile-access/login` || target.decodedPathname === `/mobile-access/login.js`)) {
6124
6432
  if (target.decodedPathname.endsWith(".js") && target.search !== "") throw new HttpError(400, "bad_request");
6125
6433
  const languageHeader = request.headers["accept-language"];
6126
- const locale = resolveAuthPageLocale(typeof languageHeader === "string" ? languageHeader : void 0);
6434
+ const locale = resolveBrowserUiLocale(request.headers.cookie, typeof languageHeader === "string" ? languageHeader : void 0);
6127
6435
  setSecurityHeaders(response, this.tlsEnabled);
6128
- response.setHeader("Vary", "Accept-Language");
6436
+ response.setHeader("Vary", "Accept-Language, Cookie");
6129
6437
  const body = target.decodedPathname.endsWith(".js") ? renderLoginScript(locale) : renderLoginPage(locale);
6130
6438
  response.writeHead(200, {
6131
6439
  "Content-Type": target.decodedPathname.endsWith(".js") ? "text/javascript; charset=utf-8" : "text/html; charset=utf-8",
@@ -6209,6 +6517,8 @@ var MobileAccessGateway = class {
6209
6517
  }
6210
6518
  throw error;
6211
6519
  }
6520
+ const viewId = headerValue(request.headers, MOBILE_DOCUMENT_VIEW_HEADER);
6521
+ const documentView = viewId === void 0 ? void 0 : this.requireMobileDocumentView(viewId, authorization, request);
6212
6522
  if (isMutation) try {
6213
6523
  this.requireCsrf(request, authorization);
6214
6524
  } catch (error) {
@@ -6336,7 +6646,7 @@ var MobileAccessGateway = class {
6336
6646
  return;
6337
6647
  }
6338
6648
  if (stockFrontend && target.decodedPathname === "/") request.url = "/";
6339
- await this.proxyHttp(request, response, authorization);
6649
+ await this.proxyHttp(request, response, authorization, documentView);
6340
6650
  }
6341
6651
  async handleExtensionRequest(targetInfo, target, request, response, authorization) {
6342
6652
  const extensions = this.extensions;
@@ -6568,16 +6878,23 @@ var MobileAccessGateway = class {
6568
6878
  }
6569
6879
  }
6570
6880
  async exchangeUpstreamCookie() {
6571
- const authenticatedUrl = this.upstreamAuthenticatedUrl;
6572
- if (authenticatedUrl === void 0) throw new HttpError(502, "upstream_unavailable");
6573
- let target;
6574
- try {
6575
- target = new URL(authenticatedUrl);
6576
- } catch {
6577
- throw new HttpError(502, "upstream_unavailable");
6578
- }
6579
- if (target.origin !== this.config.upstreamOrigin.origin || target.pathname !== "/" || target.hash !== "" || target.search === "") throw new HttpError(502, "upstream_unavailable");
6881
+ const attempt = this.upstreamAuthDiagnostics.begin();
6882
+ let stage = "target";
6883
+ let outcome = "invalid-target";
6884
+ let status;
6885
+ let timedOut = false;
6580
6886
  try {
6887
+ const authenticatedUrl = this.upstreamAuthenticatedUrl;
6888
+ if (authenticatedUrl === void 0) throw new HttpError(502, "upstream_unavailable");
6889
+ let target;
6890
+ try {
6891
+ target = new URL(authenticatedUrl);
6892
+ } catch {
6893
+ throw new HttpError(502, "upstream_unavailable");
6894
+ }
6895
+ if (target.origin !== this.config.upstreamOrigin.origin || target.pathname !== "/" || target.hash !== "" || target.search === "") throw new HttpError(502, "upstream_unavailable");
6896
+ stage = "request";
6897
+ outcome = "transport-error";
6581
6898
  const proxied = await new Promise((resolve, reject) => {
6582
6899
  const upstreamRequest = request({
6583
6900
  protocol: "http:",
@@ -6595,27 +6912,46 @@ var MobileAccessGateway = class {
6595
6912
  });
6596
6913
  this.upstreamAuthRequest = upstreamRequest;
6597
6914
  upstreamRequest.setTimeout(this.config.upstreamTimeoutMs, () => {
6915
+ timedOut = true;
6598
6916
  upstreamRequest.destroy(new HttpError(504, "upstream_timeout"));
6599
6917
  });
6600
6918
  upstreamRequest.once("response", resolve);
6601
6919
  upstreamRequest.once("error", reject);
6602
6920
  upstreamRequest.end();
6603
6921
  });
6922
+ stage = "response";
6923
+ status = proxied.statusCode;
6604
6924
  await new Promise((resolve, reject) => {
6605
6925
  proxied.once("end", resolve);
6606
6926
  proxied.once("error", reject);
6607
6927
  proxied.resume();
6608
6928
  });
6929
+ if (status !== 303) {
6930
+ outcome = "unexpected-status";
6931
+ throw new HttpError(502, "upstream_unavailable");
6932
+ }
6933
+ stage = "cookie";
6934
+ outcome = "invalid-cookie";
6609
6935
  const setCookie = proxied.headers["set-cookie"]?.[0];
6610
6936
  const pair = setCookie?.split(";", 1)[0];
6611
6937
  const maxAgeText = setCookie === void 0 ? void 0 : /(?:^|;\s*)Max-Age=(\d+)(?:;|$)/iu.exec(setCookie)?.[1];
6612
6938
  const maxAgeSeconds = maxAgeText === void 0 ? NaN : Number(maxAgeText);
6613
6939
  const expiresAt = Date.now() + maxAgeSeconds * 1e3;
6614
- if (proxied.statusCode !== 303 || pair === void 0 || pair.length > 4096 || !UPSTREAM_COOKIE_PAIR.test(pair) || !Number.isSafeInteger(expiresAt) || maxAgeSeconds <= 0) throw new HttpError(502, "upstream_unavailable");
6940
+ if (pair === void 0 || pair.length > 4096 || !UPSTREAM_COOKIE_PAIR.test(pair) || !Number.isSafeInteger(expiresAt) || maxAgeSeconds <= 0) throw new HttpError(502, "upstream_unavailable");
6615
6941
  this.upstreamCookie = pair;
6616
6942
  this.upstreamCookieExpiresAt = expiresAt;
6943
+ this.upstreamAuthDiagnostics.record(attempt, {
6944
+ outcome: "accepted",
6945
+ stage,
6946
+ status
6947
+ });
6617
6948
  return pair;
6618
6949
  } catch (error) {
6950
+ this.upstreamAuthDiagnostics.record(attempt, {
6951
+ outcome: timedOut || error instanceof HttpError && error.status === 504 ? "timeout" : outcome,
6952
+ stage,
6953
+ ...status === void 0 ? {} : { status }
6954
+ });
6619
6955
  if (error instanceof HttpError) throw error;
6620
6956
  throw new HttpError(502, "upstream_unavailable");
6621
6957
  } finally {
@@ -6789,11 +7125,15 @@ var MobileAccessGateway = class {
6789
7125
  };
6790
7126
  const cookies = browserAuthCookies(request.headers.cookie);
6791
7127
  const csrfCookie = this.tlsEnabled && cookies.has("__Host-dsh_ma_session") ? HOST_CSRF_COOKIE : CSRF_COOKIE;
7128
+ const viewId = randomBytes(16).toString("hex");
6792
7129
  const rewritten = rewriteMobileIndexWithBatch(html, {
6793
7130
  ...options,
6794
- csrfCookie
7131
+ csrfCookie,
7132
+ viewId
6795
7133
  });
7134
+ operation.signal.throwIfAborted();
6796
7135
  for (const batch of rewritten.batches) this.rememberMobileBootBatch(batch);
7136
+ if (rewritten.graphTransport) this.rememberMobileDocumentView(viewId, authorization, excludedClientModules);
6797
7137
  body = Buffer.from(rewritten.html);
6798
7138
  } catch (error) {
6799
7139
  if (error instanceof ExcludedClientModulesError) {
@@ -6845,6 +7185,53 @@ var MobileAccessGateway = class {
6845
7185
  operation.release();
6846
7186
  }
6847
7187
  }
7188
+ removeMobileDocumentView(view) {
7189
+ if (this.mobileDocumentViews.get(view.id) !== view) return;
7190
+ this.mobileDocumentViews.delete(view.id);
7191
+ for (const abort of view.abortStreams) abort();
7192
+ view.abortStreams.clear();
7193
+ }
7194
+ rememberMobileDocumentView(id, authorization, excluded) {
7195
+ for (const view of this.mobileDocumentViews.values()) if (view.expiresAt <= Date.now()) this.removeMobileDocumentView(view);
7196
+ const view = Object.freeze({
7197
+ ...authorization,
7198
+ id,
7199
+ excludedClientModules: Object.freeze([...excluded]),
7200
+ abortStreams: /* @__PURE__ */ new Set()
7201
+ });
7202
+ this.mobileDocumentViews.set(id, view);
7203
+ while (this.mobileDocumentViews.size > this.config.maxSessions) {
7204
+ const oldest = this.mobileDocumentViews.values().next().value;
7205
+ if (oldest === void 0) break;
7206
+ this.removeMobileDocumentView(oldest);
7207
+ }
7208
+ }
7209
+ requireMobileDocumentView(id, authorization, request) {
7210
+ const view = /^[a-f0-9]{32}$/u.test(id) ? this.mobileDocumentViews.get(id) : void 0;
7211
+ if (view === void 0 || view.expiresAt <= Date.now() || view.sessionKey !== authorization.sessionKey || view.deviceId !== authorization.deviceId || request.method !== "GET" || parseRequestTarget(request.url).decodedPathname !== "/plugins/events" || request.headers["transfer-encoding"] !== void 0 || request.headers["content-length"] !== void 0 && Number(request.headers["content-length"]) !== 0) throw new HttpError(409, "mobile_document_view_invalid");
7212
+ return view;
7213
+ }
7214
+ async projectMobileDocumentEvent(view, value, signal) {
7215
+ signal.throwIfAborted();
7216
+ if (this.mobileDocumentViews.get(view.id) !== view) throw new HttpError(409, "mobile_document_view_invalid");
7217
+ if (value === null || typeof value !== "object" || Array.isArray(value)) throw new HttpError(502, "upstream_unavailable");
7218
+ const frame = value;
7219
+ if (frame.type === "rebuilt") {
7220
+ if (typeof frame.id !== "string" || typeof frame.rev !== "string") throw new HttpError(502, "upstream_unavailable");
7221
+ return frame.id === MOBILE_LAYOUT_MODULE ? void 0 : frame;
7222
+ }
7223
+ if (frame.type !== "graph" || frame.graph === null || typeof frame.graph !== "object" || Array.isArray(frame.graph)) throw new HttpError(502, "upstream_unavailable");
7224
+ const plan = parseMobileBootGraph(JSON.stringify(frame.graph), view.excludedClientModules);
7225
+ const projected = projectMobileBootGraph(plan, plan.rewrittenBatchEntries === void 0 ? {} : await this.resolveMobileBootSizes(plan.rewrittenBatchEntries.flatMap((batch) => batch.entries), signal));
7226
+ if (!projected.graphTransport) throw new HttpError(502, "upstream_unavailable");
7227
+ signal.throwIfAborted();
7228
+ if (this.mobileDocumentViews.get(view.id) !== view) throw new HttpError(409, "mobile_document_view_invalid");
7229
+ for (const batch of projected.batches) this.rememberMobileBootBatch(batch);
7230
+ return {
7231
+ ...frame,
7232
+ graph: plan.parsed
7233
+ };
7234
+ }
6848
7235
  rememberMobileBootBatch(plan) {
6849
7236
  const existing = this.mobileBootBatches.get(plan.key);
6850
7237
  this.mobileBootBatches.delete(plan.key);
@@ -6877,7 +7264,8 @@ var MobileAccessGateway = class {
6877
7264
  else sizes.set(source, size);
6878
7265
  }
6879
7266
  };
6880
- await Promise.all(Array.from({ length: Math.min(8, candidates.length) }, worker));
7267
+ const failed = (await Promise.allSettled(Array.from({ length: Math.min(8, candidates.length) }, worker))).find((result) => result.status === "rejected");
7268
+ if (failed?.status === "rejected") throw failed.reason;
6881
7269
  return {
6882
7270
  sizes,
6883
7271
  passThrough
@@ -6896,6 +7284,7 @@ var MobileAccessGateway = class {
6896
7284
  const cached = this.bootEntrySizeCache.get(source);
6897
7285
  if (cached !== void 0 && Date.now() - cached.at < MOBILE_BOOT_SIZE_CACHE_TTL_MS) return cached.size;
6898
7286
  let upstreamRequest;
7287
+ let upstreamClosed;
6899
7288
  const abort = () => {
6900
7289
  upstreamRequest?.destroy(requestAbortedError());
6901
7290
  };
@@ -6919,6 +7308,12 @@ var MobileAccessGateway = class {
6919
7308
  },
6920
7309
  agent: false
6921
7310
  });
7311
+ const opened = upstreamRequest;
7312
+ upstreamClosed = new Promise((closed) => {
7313
+ opened.once("close", () => {
7314
+ closed();
7315
+ });
7316
+ });
6922
7317
  upstreamRequest.setTimeout(this.config.upstreamTimeoutMs, () => {
6923
7318
  upstreamRequest?.destroy(upstreamTimeoutError());
6924
7319
  });
@@ -6954,6 +7349,7 @@ var MobileAccessGateway = class {
6954
7349
  } finally {
6955
7350
  signal?.removeEventListener("abort", abort);
6956
7351
  upstreamRequest?.destroy();
7352
+ await upstreamClosed;
6957
7353
  }
6958
7354
  }
6959
7355
  async serveMobileBootBatch(key, request, response, authorization) {
@@ -7152,14 +7548,15 @@ var MobileAccessGateway = class {
7152
7548
  * One request slot and cancellation signal cover attempts and backoff together.
7153
7549
  * API requests and requests with bodies are forwarded once.
7154
7550
  */
7155
- async proxyHttp(request, response, authorization) {
7551
+ async proxyHttp(request, response, authorization, documentView) {
7156
7552
  const declared = request.headers["content-length"];
7157
7553
  if (declared !== void 0 && (!/^\d+$/u.test(declared) || Number(declared) > this.config.maxBodyBytes)) throw new HttpError(413, "payload_too_large");
7158
7554
  const replayable = request.method === "GET" && request.headers["transfer-encoding"] === void 0 && (declared === void 0 || Number(declared) === 0) && (request.url?.split("?", 1)[0]?.startsWith("/plugins/") === true || request.url?.split("?", 1)[0]?.startsWith("/assets/") === true);
7159
7555
  const holder = {};
7160
7556
  const operation = this.allocateRequest(authorization, response, holder);
7557
+ documentView?.abortStreams.add(operation.abort);
7161
7558
  const onClose = () => {
7162
- operation.release();
7559
+ if (documentView === void 0) operation.release();
7163
7560
  operation.abort();
7164
7561
  };
7165
7562
  response.once("close", onClose);
@@ -7169,7 +7566,7 @@ var MobileAccessGateway = class {
7169
7566
  for (let attempt = 1; attempt <= attempts; attempt++) {
7170
7567
  operation.signal.throwIfAborted();
7171
7568
  try {
7172
- await this.proxyHttpOnce(request, response, holder, operation.signal, replayable);
7569
+ await this.proxyHttpOnce(request, response, holder, operation.signal, replayable, documentView);
7173
7570
  return;
7174
7571
  } catch (error) {
7175
7572
  if (operation.signal.aborted || response.destroyed) return;
@@ -7186,13 +7583,18 @@ var MobileAccessGateway = class {
7186
7583
  if (!operation.signal.aborted) throw error;
7187
7584
  } finally {
7188
7585
  response.removeListener("close", onClose);
7586
+ documentView?.abortStreams.delete(operation.abort);
7189
7587
  operation.release();
7190
7588
  }
7191
7589
  }
7192
- async proxyHttpOnce(request$2, response, holder, signal, replayable) {
7590
+ async proxyHttpOnce(request$2, response, holder, signal, replayable, documentView) {
7193
7591
  let bodyDone;
7592
+ const projections = /* @__PURE__ */ new Set();
7194
7593
  try {
7594
+ const pathname = parseRequestTarget(request$2.url).decodedPathname;
7195
7595
  const upstreamHeaders = sanitizeRequestHeaders(request$2, this.config.upstreamOrigin);
7596
+ delete upstreamHeaders[MOBILE_DOCUMENT_VIEW_HEADER];
7597
+ if (documentView !== void 0) upstreamHeaders["accept-encoding"] = "identity";
7196
7598
  if (request$2.headers["transfer-encoding"] !== void 0) upstreamHeaders["transfer-encoding"] = request$2.headers["transfer-encoding"];
7197
7599
  const upstreamCookie = await this.upstreamCookieHeader();
7198
7600
  signal.throwIfAborted();
@@ -7211,7 +7613,6 @@ var MobileAccessGateway = class {
7211
7613
  upstreamRequest.setTimeout(this.config.upstreamTimeoutMs, () => {
7212
7614
  upstreamRequest.destroy(new HttpError(504, "upstream_timeout"));
7213
7615
  });
7214
- const pathname = parseRequestTarget(request$2.url).decodedPathname;
7215
7616
  if (pathname === "/api" || pathname.startsWith("/api/")) upstreamRequest.once("finish", () => {
7216
7617
  upstreamRequest.setTimeout(this.config.upstreamApiTimeoutMs);
7217
7618
  });
@@ -7223,13 +7624,28 @@ var MobileAccessGateway = class {
7223
7624
  bodyDone.catch(reject);
7224
7625
  }
7225
7626
  });
7627
+ const graphEvents = documentView !== void 0 && replayable && pathname === "/plugins/events" && proxied.statusCode === 200 && proxied.headers["content-type"]?.split(";", 1)[0]?.trim().toLowerCase() === "text/event-stream";
7628
+ if (documentView !== void 0 && !graphEvents) {
7629
+ proxied.destroy();
7630
+ throw new HttpError(502, "upstream_unavailable");
7631
+ }
7632
+ if (graphEvents) holder.request?.setTimeout(this.config.upstreamApiTimeoutMs);
7226
7633
  setSecurityHeaders(response, this.tlsEnabled, "proxied");
7227
7634
  const headers = sanitizeResponseHeaders(proxied.headers, this.config.upstreamOrigin);
7635
+ if (graphEvents) {
7636
+ if (proxied.headers["content-encoding"] !== void 0 && proxied.headers["content-encoding"] !== "identity") throw new HttpError(502, "upstream_unavailable");
7637
+ delete headers["content-length"];
7638
+ delete headers["content-encoding"];
7639
+ delete headers.etag;
7640
+ delete headers["last-modified"];
7641
+ delete headers["content-range"];
7642
+ delete headers["accept-ranges"];
7643
+ }
7228
7644
  const statusCode = proxied.statusCode ?? 502;
7229
7645
  this.httpFailures.recordUpstream(request$2.method, request$2.url?.split("?", 1)[0] ?? "", statusCode);
7230
- const cacheControl = revisionedStaticCacheControl(request$2, statusCode);
7646
+ const cacheControl = graphEvents ? void 0 : revisionedStaticCacheControl(request$2, statusCode);
7231
7647
  if (cacheControl !== void 0) headers["cache-control"] = cacheControl;
7232
- const compressible = canCompressResponse(request$2, proxied);
7648
+ const compressible = !graphEvents && canCompressResponse(request$2, proxied);
7233
7649
  const compressed = compressible && acceptsGzip(request$2.headers["accept-encoding"]);
7234
7650
  if (compressible) addVaryAcceptEncoding(headers);
7235
7651
  if (compressed) {
@@ -7250,7 +7666,18 @@ var MobileAccessGateway = class {
7250
7666
  addVaryAcceptEncoding(headers);
7251
7667
  }
7252
7668
  response.writeHead(statusCode, headers);
7253
- await Promise.all([bodyDone, compressed && request$2.method !== "HEAD" ? pipeline(proxied, createGzip(), response) : pipeline(proxied, response)]);
7669
+ const eventTransform = !graphEvents || documentView === void 0 ? void 0 : createMobileGraphEventTransform((value) => {
7670
+ const task = this.projectMobileDocumentEvent(documentView, value, signal);
7671
+ projections.add(task);
7672
+ this.mobileGraphTasks.add(task);
7673
+ const settled = () => {
7674
+ projections.delete(task);
7675
+ this.mobileGraphTasks.delete(task);
7676
+ };
7677
+ task.then(settled, settled);
7678
+ return task;
7679
+ });
7680
+ await Promise.all([bodyDone, eventTransform !== void 0 ? pipeline(proxied, eventTransform, response) : compressed && request$2.method !== "HEAD" ? pipeline(proxied, createGzip(), response) : pipeline(proxied, response)]);
7254
7681
  } catch (error) {
7255
7682
  holder.request?.destroy();
7256
7683
  await bodyDone?.catch(() => void 0);
@@ -7258,6 +7685,7 @@ var MobileAccessGateway = class {
7258
7685
  if (response.headersSent) response.destroy();
7259
7686
  throw error;
7260
7687
  } finally {
7688
+ await Promise.allSettled(projections);
7261
7689
  delete holder.request;
7262
7690
  }
7263
7691
  }
@@ -7648,9 +8076,13 @@ var MobileAccessGateway = class {
7648
8076
  this.upstreamAuthRequest = void 0;
7649
8077
  this.removeSessionListener();
7650
8078
  this.pendingDeviceRevocations.clear();
8079
+ for (const view of this.mobileDocumentViews.values()) this.removeMobileDocumentView(view);
7651
8080
  const accessClose = this.access.close();
7652
8081
  for (const stored of this.mobileBootBatches.values()) stored.assembly?.controller.abort();
7653
- for (const request of this.activeRequests.values()) request.abort();
8082
+ for (const request of this.activeRequests.values()) {
8083
+ clearTimeout(request.timer);
8084
+ request.abort();
8085
+ }
7654
8086
  for (const websocket of this.activeWebSockets.values()) {
7655
8087
  websocket.client.destroy();
7656
8088
  websocket.upstream.destroy();
@@ -7675,6 +8107,7 @@ var MobileAccessGateway = class {
7675
8107
  await accessClose;
7676
8108
  await Promise.allSettled(this.clientModuleTasks);
7677
8109
  await Promise.allSettled(this.clientModuleRevocations);
8110
+ await Promise.allSettled(this.mobileGraphTasks);
7678
8111
  this.activeRequests.clear();
7679
8112
  this.activeWebSockets.clear();
7680
8113
  this.connectedSockets.clear();
@@ -17264,16 +17697,20 @@ async function loadOrCreateInstallationId(file) {
17264
17697
  //#endregion
17265
17698
  //#region src/gateway-identity.ts
17266
17699
  /**
17267
- * Use the persisted installation identity for an unpinned, automatically identified entry.
17700
+ * Attach the authenticated installation identity while retaining existing entry identity rules.
17268
17701
  * @param config - Validated gateway configuration containing the legacy identity.
17269
17702
  * @param installationId - Confirmed random identity read from this installation's state.
17270
17703
  * @param explicitIdentity - Whether the operator supplied the gateway identity.
17271
17704
  * @returns Configuration retaining certificate/operator identities or the new and legacy public identities.
17272
17705
  */
17273
17706
  function withInstallationIdentity(config, installationId, explicitIdentity) {
17274
- if (config.pairingCaFile !== void 0 || explicitIdentity) return config;
17707
+ if (config.pairingCaFile !== void 0 || explicitIdentity) return Object.freeze({
17708
+ ...config,
17709
+ installationId
17710
+ });
17275
17711
  return Object.freeze({
17276
17712
  ...config,
17713
+ installationId,
17277
17714
  instanceId: installationId,
17278
17715
  legacyInstanceId: config.instanceId
17279
17716
  });
@@ -17659,10 +18096,10 @@ async function apply(ctx, config) {
17659
18096
  const dshVersion = installedDshVersion();
17660
18097
  const loaded = await loadSetup(config);
17661
18098
  const mobileAccess = createMobileAccessService(ctx);
17662
- const template = loopbackTemplate(loaded, ctx.webServer.port);
17663
- const upstreamLoginUrl = upstreamAuthenticatedUrl(ctx, template.upstreamOrigin);
17664
- const instanceId = await stableInstanceId(loaded, template);
17665
- const stateDirectory = dirname(template.stateFile);
18099
+ const baseTemplate = loopbackTemplate(loaded, ctx.webServer.port);
18100
+ const upstreamLoginUrl = upstreamAuthenticatedUrl(ctx, baseTemplate.upstreamOrigin);
18101
+ const instanceId = await stableInstanceId(loaded, baseTemplate);
18102
+ const stateDirectory = dirname(baseTemplate.stateFile);
17666
18103
  const logFile = await installMobileFileLogger(ctx, stateDirectory);
17667
18104
  const logger = ctx.logger("dsh-mobile");
17668
18105
  logger.info("logging initialized file=%s", logFile);
@@ -17685,6 +18122,10 @@ async function apply(ctx, config) {
17685
18122
  const frpComponent = new FrpComponentManager({ stateDirectory });
17686
18123
  await frpComponent.initialize();
17687
18124
  const frpIdentity = await loadOrCreateInstallationId(join(stateDirectory, "installation-id"));
18125
+ const template = Object.freeze({
18126
+ ...baseTemplate,
18127
+ installationId: frpIdentity
18128
+ });
17688
18129
  const remoteInstanceId = loaded.kind === "managed" ? frpIdentity : loaded.config.instanceId ?? frpIdentity;
17689
18130
  const frpConfig = new FrpConfigStore(join(remoteDirectory, "frp", "config"), frpProxyName(frpIdentity), stateDirectory);
17690
18131
  await frpConfig.initialize();
@@ -17747,6 +18188,8 @@ async function apply(ctx, config) {
17747
18188
  logger.warn("auth failure %s", JSON.stringify(entry));
17748
18189
  }, (entry) => {
17749
18190
  logger.warn("http failure %s", JSON.stringify(entry));
18191
+ }, (entry) => {
18192
+ logger.info("upstream auth %s", JSON.stringify(entry));
17750
18193
  });
17751
18194
  await candidate.start();
17752
18195
  lanGateway = candidate;
@@ -17807,6 +18250,8 @@ async function apply(ctx, config) {
17807
18250
  logger.warn("auth failure %s", JSON.stringify(entry));
17808
18251
  }, (entry) => {
17809
18252
  logger.warn("http failure %s", JSON.stringify(entry));
18253
+ }, (entry) => {
18254
+ logger.info("upstream auth %s", JSON.stringify(entry));
17810
18255
  });
17811
18256
  await candidate.start();
17812
18257
  return candidate;
@@ -17827,6 +18272,8 @@ async function apply(ctx, config) {
17827
18272
  logger.warn("auth failure %s", JSON.stringify(entry));
17828
18273
  }, (entry) => {
17829
18274
  logger.warn("http failure %s", JSON.stringify(entry));
18275
+ }, (entry) => {
18276
+ logger.info("upstream auth %s", JSON.stringify(entry));
17830
18277
  });
17831
18278
  try {
17832
18279
  await candidate.start();
@@ -17842,6 +18289,8 @@ async function apply(ctx, config) {
17842
18289
  logger.warn("auth failure %s", JSON.stringify(entry));
17843
18290
  }, (entry) => {
17844
18291
  logger.warn("http failure %s", JSON.stringify(entry));
18292
+ }, (entry) => {
18293
+ logger.info("upstream auth %s", JSON.stringify(entry));
17845
18294
  });
17846
18295
  try {
17847
18296
  await candidate.start();