dsh-mobile 0.4.6 → 0.4.7

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.d.mts CHANGED
@@ -262,6 +262,8 @@ interface PluginConfig {
262
262
  mobileLayoutFile?: string;
263
263
  /** Standalone browser compatibility bundle used before DSH boot. */
264
264
  mobileCompatibilityFile?: string;
265
+ /** Optional application-phase client package ids omitted only from the mobile boot graph. */
266
+ excludedClientModules?: string[];
265
267
  /** Stable public discovery identifier; it is not an authentication secret. */
266
268
  instanceId?: string;
267
269
  /** Managed CA certificate offered to the Android installer after fingerprint binding. */
@@ -302,6 +304,7 @@ interface ResolvedGatewayConfig {
302
304
  readonly customScriptFile: string;
303
305
  readonly mobileLayoutFile: string;
304
306
  readonly mobileCompatibilityFile: string;
307
+ readonly excludedClientModules: readonly string[];
305
308
  readonly instanceId: string;
306
309
  readonly pairingCaFile?: string;
307
310
  readonly tls: TlsConfig;
@@ -589,8 +592,8 @@ declare class MobileAccessService extends Service {
589
592
  declare function createMobileAccessService(ctx: Context): MobileAccessService;
590
593
  //#endregion
591
594
  //#region src/gateway.d.ts
592
- /** Replace only DSH's layout client module while retaining its complete plugin graph. */
593
- declare function rewriteMobileIndex(html: string): string;
595
+ /** Rewrite the dedicated mobile graph; exclusions are opt-in and leave stock DSH unchanged. */
596
+ declare function rewriteMobileIndex(html: string, excludedClientModules?: readonly string[]): string;
594
597
  /** Authenticated TLS edge in front of the ordinary loopback-only DSH Web server. */
595
598
  declare class MobileAccessGateway {
596
599
  readonly config: ResolvedGatewayConfig;
@@ -616,6 +619,7 @@ declare class MobileAccessGateway {
616
619
  private mdnsError;
617
620
  /** Names of the bundled mobile assets that could not be read, if any. */
618
621
  private mobileAssetError;
622
+ private lastModuleExclusionError;
619
623
  private bonjour;
620
624
  private pairingCaCertificate;
621
625
  private listenerPort;
package/lib/index.mjs CHANGED
@@ -761,6 +761,7 @@ const Config = z.object({
761
761
  customScriptFile: z.string().hidden(),
762
762
  mobileLayoutFile: z.string().hidden(),
763
763
  mobileCompatibilityFile: z.string().hidden(),
764
+ excludedClientModules: z.array(String).default([]),
764
765
  instanceId: z.string().hidden(),
765
766
  pairingCaFile: z.string().hidden(),
766
767
  initiallyEnabled: z.boolean().hidden().required(),
@@ -797,6 +798,13 @@ function absoluteFile(value, name) {
797
798
  if (typeof value !== "string" || value.length === 0 || !isAbsolute(value)) throw new Error(`${name} must be an absolute file path`);
798
799
  return resolve(value);
799
800
  }
801
+ function excludedClientModules(value) {
802
+ if (value === void 0) return Object.freeze([]);
803
+ if (!Array.isArray(value) || value.some((id) => typeof id !== "string" || id.length === 0 || id.trim() !== id)) throw new Error("excludedClientModules must be an array of exact, non-empty client module ids");
804
+ const ids = value;
805
+ if (new Set(ids).size !== ids.length) throw new Error("excludedClientModules must not contain duplicate ids");
806
+ return Object.freeze([...ids]);
807
+ }
800
808
  /** Resolve the hidden runtime-control file independently from gateway configuration. */
801
809
  function parseControlFile(value) {
802
810
  return absoluteFile(value, "controlFile");
@@ -887,6 +895,7 @@ function parseGatewayConfig(raw) {
887
895
  customScriptFile: value.customScriptFile === void 0 ? join(dirname(absoluteFile(value.stateFile, "stateFile")), "mobile.js") : absoluteFile(value.customScriptFile, "customScriptFile"),
888
896
  mobileLayoutFile: value.mobileLayoutFile === void 0 ? fileURLToPath(new URL("./mobile-layout.js", import.meta.url)) : absoluteFile(value.mobileLayoutFile, "mobileLayoutFile"),
889
897
  mobileCompatibilityFile: value.mobileCompatibilityFile === void 0 ? fileURLToPath(new URL("./mobile-compat.js", import.meta.url)) : absoluteFile(value.mobileCompatibilityFile, "mobileCompatibilityFile"),
898
+ excludedClientModules: excludedClientModules(value.excludedClientModules),
890
899
  instanceId: value.instanceId === void 0 ? createHash("sha256").update(absoluteFile(value.stateFile, "stateFile")).digest("hex") : /^[a-f\d]{64}$/u.test(value.instanceId) ? value.instanceId : (() => {
891
900
  throw new Error("instanceId must be a lowercase SHA-256 value");
892
901
  })(),
@@ -2614,6 +2623,17 @@ const SIDEBAR_MODULE = "@deepseek-ai/dsh-client-ui-sidebar";
2614
2623
  const SETTINGS_MODULE = "@deepseek-ai/dsh-client-ui-settings";
2615
2624
  const API_GATEWAY_MODULE = "@deepseek-ai/dsh-api-gateway";
2616
2625
  const API_REMOTES_MODULE = "@deepseek-ai/dsh-api-remotes";
2626
+ const MOBILE_BOOT_CORE_MODULES = /* @__PURE__ */ new Set([
2627
+ MOBILE_CLIENT_MODULE,
2628
+ MOBILE_LAYOUT_MODULE,
2629
+ CONNECTION_MODULE,
2630
+ RUNTIME_MODULE,
2631
+ RENDERER_MODULE,
2632
+ SIDEBAR_MODULE,
2633
+ SETTINGS_MODULE,
2634
+ API_GATEWAY_MODULE,
2635
+ API_REMOTES_MODULE
2636
+ ]);
2617
2637
  const MOBILE_LAYOUT_DEPENDENCY_PROFILES = Object.freeze([Object.freeze({
2618
2638
  slots: RUNTIME_MODULE,
2619
2639
  dependencies: Object.freeze([RUNTIME_MODULE, "@deepseek-ai/dsh-client-ui-theme"])
@@ -2719,13 +2739,49 @@ function revisionedMobileBatchPath(entries) {
2719
2739
  path: `${MOBILE_BOOT_BATCH_PREFIX}${key}.js`
2720
2740
  };
2721
2741
  }
2742
+ var ExcludedClientModulesError = class extends Error {
2743
+ constructor(message) {
2744
+ super(`excludedClientModules: ${message}`);
2745
+ }
2746
+ };
2747
+ /** Validate graph edges before removing application rows from both manifest views. */
2748
+ function excludeMobileBootEntries(entries, batches, excludedIds) {
2749
+ if (excludedIds.length === 0) return;
2750
+ const excluded = new Set(excludedIds);
2751
+ if (excluded.size !== excludedIds.length) throw new ExcludedClientModulesError("duplicate module ids");
2752
+ const byId = new Map(entries.map((entry) => [entry.id, entry]));
2753
+ const phases = /* @__PURE__ */ new Map();
2754
+ for (const batch of batches) for (const id of batch.entries) {
2755
+ if (phases.has(id)) throw new ExcludedClientModulesError(`upstream module ${id} belongs to multiple batches`);
2756
+ phases.set(id, batch.phase);
2757
+ }
2758
+ for (const entry of entries) if (!phases.has(entry.id)) throw new ExcludedClientModulesError(`upstream module ${entry.id} belongs to no batch`);
2759
+ for (const id of excluded) {
2760
+ const entry = byId.get(id);
2761
+ if (entry === void 0) throw new ExcludedClientModulesError(`module ${id} is not installed in this DSH client graph`);
2762
+ if (MOBILE_BOOT_CORE_MODULES.has(id) || entry.immediately === true || phases.get(id) !== "application") throw new ExcludedClientModulesError(`module ${id} is required for mobile boot or belongs to a bootstrap batch`);
2763
+ }
2764
+ for (const entry of entries) {
2765
+ if (excluded.has(entry.id)) continue;
2766
+ for (const [field, edges] of [["inject", entry.inject], ["external", entry.external]]) {
2767
+ if (edges !== void 0 && (!Array.isArray(edges) || edges.some((id) => typeof id !== "string"))) throw new ExcludedClientModulesError(`upstream module ${entry.id} has malformed ${field} dependencies`);
2768
+ for (const dependency of edges ?? []) {
2769
+ const packageId = dependency.endsWith("/client") ? dependency.slice(0, -7) : dependency;
2770
+ if (excluded.has(packageId)) throw new ExcludedClientModulesError(`module ${entry.id} still depends on excluded module ${packageId}; exclude the dependent too or keep ${packageId}`);
2771
+ }
2772
+ }
2773
+ }
2774
+ entries.splice(0, entries.length, ...entries.filter((entry) => !excluded.has(entry.id)));
2775
+ for (const batch of batches) batch.entries = batch.entries.filter((id) => !excluded.has(id));
2776
+ batches.splice(0, batches.length, ...batches.filter((batch) => batch.entries.length > 0));
2777
+ }
2722
2778
  /**
2723
2779
  * Parse one upstream boot manifest and rewire the layout entry onto the
2724
- * gateway-owned layout module. The layout entry's URL and revision become the
2725
- * local `mobile-layout.js` path; the dedicated frontend is authenticated in the
2726
- * gateway, so the requested layout needs no upstream address.
2780
+ * gateway-owned layout module. Optional exclusions remove application entries
2781
+ * and identify every affected batch for reassembly. The dedicated frontend is
2782
+ * authenticated in the gateway, so the layout needs no upstream address.
2727
2783
  */
2728
- function parseMobileBootPlan(html) {
2784
+ function parseMobileBootPlan(html, excludedClientModules = []) {
2729
2785
  const assignment = /(?:window\.__DSH_BOOT__|globalThis\["__DSH_BOOT__"\])\s*=\s*/u.exec(html);
2730
2786
  if (assignment?.index === void 0) throw new Error("upstream DSH index has no boot manifest");
2731
2787
  const replaceStart = assignment.index;
@@ -2744,14 +2800,17 @@ function parseMobileBootPlan(html) {
2744
2800
  layout[0].url = MOBILE_LAYOUT_PATH;
2745
2801
  layout[0].rev = `dsh-mobile-layout-${DSH_MOBILE_VERSION}`;
2746
2802
  const parsedBatches = parsed.batches;
2747
- if (parsedBatches === void 0) return Object.freeze({
2748
- parsed,
2749
- entries,
2750
- slotProvider: dependencyProfile.slots,
2751
- assignment: assignment[0],
2752
- replaceStart,
2753
- replaceEnd: scriptEnd
2754
- });
2803
+ if (parsedBatches === void 0) {
2804
+ if (excludedClientModules.length > 0) throw new ExcludedClientModulesError("this DSH version has no batched boot manifest; disable exclusions or update DSH");
2805
+ return Object.freeze({
2806
+ parsed,
2807
+ entries,
2808
+ slotProvider: dependencyProfile.slots,
2809
+ assignment: assignment[0],
2810
+ replaceStart,
2811
+ replaceEnd: scriptEnd
2812
+ });
2813
+ }
2755
2814
  if (!Array.isArray(parsedBatches)) throw new Error("upstream DSH boot manifest batches are malformed");
2756
2815
  const batches = parsedBatches;
2757
2816
  const entryById = new Map(entries.map((entry) => [entry.id, entry]));
@@ -2763,15 +2822,26 @@ function parseMobileBootPlan(html) {
2763
2822
  }
2764
2823
  if (layoutBatches.length !== 1 || layoutBatches[0]?.phase !== "application") throw new Error("upstream DSH boot manifest has no unique application layout batch");
2765
2824
  const layoutBatch = layoutBatches[0];
2766
- const planEntries = layoutBatch.entries.map((id) => {
2767
- const entry = entryById.get(id);
2768
- if (entry === void 0 || typeof entry.url !== "string" || typeof entry.rev !== "string") throw new Error("upstream DSH boot manifest batches are malformed");
2769
- return Object.freeze({
2770
- id,
2771
- url: entry.url,
2772
- rev: entry.rev
2773
- });
2774
- });
2825
+ const originalApplicationBatches = batches.filter((batch) => batch.phase === "application").map((batch) => ({
2826
+ ...batch,
2827
+ entries: [...batch.entries]
2828
+ }));
2829
+ excludeMobileBootEntries(entries, batches, excludedClientModules);
2830
+ const affectedBatchUrls = new Set(originalApplicationBatches.filter((batch) => batch.entries.some((id) => excludedClientModules.includes(id))).map((batch) => batch.url));
2831
+ const rewrittenBatchEntries = batches.filter((batch) => batch === layoutBatch || batch.phase === "application" && affectedBatchUrls.has(batch.url)).map((batch) => ({
2832
+ batch,
2833
+ entries: [...batch.entries].sort((left, right) => left < right ? -1 : left > right ? 1 : 0).map((id) => {
2834
+ const entry = entryById.get(id);
2835
+ if (entry === void 0 || typeof entry.url !== "string" || typeof entry.rev !== "string") throw new Error("upstream DSH boot manifest batches are malformed");
2836
+ return Object.freeze({
2837
+ id,
2838
+ url: entry.url,
2839
+ rev: entry.rev
2840
+ });
2841
+ })
2842
+ }));
2843
+ const planEntries = rewrittenBatchEntries.find((candidate) => candidate.batch === layoutBatch)?.entries;
2844
+ if (planEntries === void 0) throw new Error("upstream DSH boot manifest lost the application layout batch");
2775
2845
  return Object.freeze({
2776
2846
  parsed,
2777
2847
  entries,
@@ -2781,7 +2851,9 @@ function parseMobileBootPlan(html) {
2781
2851
  replaceStart,
2782
2852
  replaceEnd: scriptEnd,
2783
2853
  layoutBatch,
2784
- planEntries
2854
+ planEntries,
2855
+ originalApplicationBatches,
2856
+ rewrittenBatchEntries
2785
2857
  });
2786
2858
  }
2787
2859
  /**
@@ -2840,30 +2912,62 @@ function splitMobileBootBatch(entries, sizes, passThrough) {
2840
2912
  rows
2841
2913
  };
2842
2914
  }
2915
+ function escapeMobilePreloadAttribute(value) {
2916
+ return value.replaceAll("&", "&amp;").replaceAll("\"", "&quot;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
2917
+ }
2918
+ function mobileApplicationPreload(url) {
2919
+ return `<link rel="preload" as="script" href="${escapeMobilePreloadAttribute(url)}">`;
2920
+ }
2921
+ function preloadAttribute(tag, name) {
2922
+ const match = new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "iu").exec(tag);
2923
+ return match?.[1] ?? match?.[2];
2924
+ }
2925
+ /** Replace only DSH's application-script preloads; bootstrap and unrelated links are untouched. */
2926
+ function rewriteMobileApplicationPreloads(html, replacements) {
2927
+ if (replacements.size === 0) return html;
2928
+ const escaped = new Map([...replacements].map(([url, markup]) => [escapeMobilePreloadAttribute(url), markup]));
2929
+ return html.replace(/<link\b[^>]*>/giu, (tag) => {
2930
+ if (!preloadAttribute(tag, "rel")?.split(/\s+/u).includes("preload") || preloadAttribute(tag, "as") !== "script") return tag;
2931
+ return escaped.get(preloadAttribute(tag, "href") ?? "") ?? tag;
2932
+ });
2933
+ }
2843
2934
  function rewriteMobileIndexWithBatch(html, options = {}) {
2844
- const plan = parseMobileBootPlan(html);
2935
+ const plan = parseMobileBootPlan(html, options.excludedClientModules);
2845
2936
  const remoteSettings = orderAuthenticatedSettings(plan.entries, plan.slotProvider);
2846
2937
  let batches = [];
2847
- if (plan.batches !== void 0 && plan.layoutBatch !== void 0 && plan.planEntries !== void 0) {
2848
- const split = splitMobileBootBatch(plan.planEntries, options.sizes ?? /* @__PURE__ */ new Map(), options.passThrough ?? /* @__PURE__ */ new Set());
2849
- const at = plan.batches.indexOf(plan.layoutBatch);
2850
- if (at < 0) throw new Error("upstream DSH boot manifest has no unique application layout batch");
2851
- plan.batches.splice(at, 1, ...split.rows);
2852
- batches = Object.freeze(split.plans);
2938
+ let preloadReplacements = /* @__PURE__ */ new Map();
2939
+ if (plan.batches !== void 0 && plan.rewrittenBatchEntries !== void 0) {
2940
+ const rewrites = plan.rewrittenBatchEntries.map(({ batch, entries }) => ({
2941
+ batch,
2942
+ split: splitMobileBootBatch(entries, options.sizes ?? /* @__PURE__ */ new Map(), options.passThrough ?? /* @__PURE__ */ new Set())
2943
+ }));
2944
+ if (rewrites.reduce((count, item) => count + item.split.plans.length, 0) > MAX_MOBILE_BOOT_BATCHES) throw new Error(`upstream DSH mobile boot requires more than ${String(MAX_MOBILE_BOOT_BATCHES)} merged batches`);
2945
+ for (const { batch, split } of rewrites) {
2946
+ const at = plan.batches.indexOf(batch);
2947
+ if (at < 0) throw new Error("upstream DSH boot manifest lost an application batch");
2948
+ plan.batches.splice(at, 1, ...split.rows);
2949
+ batches = [...batches, ...split.plans];
2950
+ }
2951
+ for (const original of plan.originalApplicationBatches ?? []) {
2952
+ const replacement = rewrites.find((item) => item.batch.url === original.url);
2953
+ preloadReplacements.set(original.url, replacement === void 0 ? plan.batches.some((batch) => batch.url === original.url) ? mobileApplicationPreload(original.url) : "" : replacement.split.rows.map((row) => mobileApplicationPreload(row.url)).join(""));
2954
+ }
2955
+ batches = Object.freeze(batches);
2853
2956
  plan.parsed.rev = createHash("sha256").update(JSON.stringify({
2854
2957
  entries: plan.entries,
2855
2958
  batches: plan.batches
2856
2959
  })).digest("hex").slice(0, 16);
2857
2960
  }
2858
2961
  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)};`;
2962
+ const rewritten = `${html.slice(0, plan.replaceStart)}${replacement}${html.slice(plan.replaceEnd)}`;
2859
2963
  return Object.freeze({
2860
- html: ensureMobileViewport(ensureMobileCompatibility(`${html.slice(0, plan.replaceStart)}${replacement}${html.slice(plan.replaceEnd)}`)),
2964
+ html: ensureMobileViewport(ensureMobileCompatibility(rewriteMobileApplicationPreloads(rewritten, preloadReplacements))),
2861
2965
  batches
2862
2966
  });
2863
2967
  }
2864
- /** Replace only DSH's layout client module while retaining its complete plugin graph. */
2865
- function rewriteMobileIndex(html) {
2866
- return rewriteMobileIndexWithBatch(html).html;
2968
+ /** Rewrite the dedicated mobile graph; exclusions are opt-in and leave stock DSH unchanged. */
2969
+ function rewriteMobileIndex(html, excludedClientModules = []) {
2970
+ return rewriteMobileIndexWithBatch(html, { excludedClientModules }).html;
2867
2971
  }
2868
2972
  var ByteLimitTransform = class extends Transform {
2869
2973
  maximum;
@@ -3307,6 +3411,7 @@ var MobileAccessGateway = class {
3307
3411
  mdnsError;
3308
3412
  /** Names of the bundled mobile assets that could not be read, if any. */
3309
3413
  mobileAssetError;
3414
+ lastModuleExclusionError;
3310
3415
  bonjour;
3311
3416
  pairingCaCertificate;
3312
3417
  listenerPort;
@@ -4276,11 +4381,26 @@ var MobileAccessGateway = class {
4276
4381
  let body;
4277
4382
  try {
4278
4383
  const html = Buffer.concat(chunks).toString("utf8");
4279
- const plan = parseMobileBootPlan(html);
4280
- const rewritten = rewriteMobileIndexWithBatch(html, plan.planEntries === void 0 ? {} : await this.resolveMobileBootSizes(plan.planEntries));
4384
+ const plan = parseMobileBootPlan(html, this.config.excludedClientModules);
4385
+ const rewritten = rewriteMobileIndexWithBatch(html, plan.rewrittenBatchEntries === void 0 ? { excludedClientModules: this.config.excludedClientModules } : {
4386
+ ...await this.resolveMobileBootSizes(plan.rewrittenBatchEntries.flatMap((batch) => batch.entries)),
4387
+ excludedClientModules: this.config.excludedClientModules
4388
+ });
4281
4389
  for (const batch of rewritten.batches) this.rememberMobileBootBatch(batch);
4282
4390
  body = Buffer.from(rewritten.html);
4283
- } catch {
4391
+ } catch (error) {
4392
+ if (error instanceof ExcludedClientModulesError) {
4393
+ const detail = error.message.replace(/[\r\n]/gu, " ").slice(0, 512);
4394
+ if (detail !== this.lastModuleExclusionError) {
4395
+ this.lastModuleExclusionError = detail;
4396
+ process.emitWarning(detail, { code: "DSH_MOBILE_MODULE_EXCLUSION_INVALID" });
4397
+ }
4398
+ sendJson(response, 409, {
4399
+ error: "excluded_client_modules_invalid",
4400
+ detail
4401
+ }, this.tlsEnabled);
4402
+ return;
4403
+ }
4284
4404
  throw new HttpError(502, "upstream_unavailable");
4285
4405
  }
4286
4406
  const headers = sanitizeResponseHeaders(proxied.headers, this.config.upstreamOrigin);