dsh-mobile 0.6.16 → 0.6.17

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
@@ -1,6 +1,6 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { X509Certificate, createHash, createPrivateKey, createPublicKey, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
3
- import { basename, dirname, extname, isAbsolute, join, relative, resolve, win32 } from "node:path";
3
+ import { basename, dirname, extname, isAbsolute, join, posix, relative, resolve, win32 } from "node:path";
4
4
  import { fileURLToPath, pathToFileURL } from "node:url";
5
5
  import z from "@deepseek-ai/schemastery";
6
6
  import { connect, createServer } from "node:net";
@@ -1973,7 +1973,7 @@ function authenticatedCookie(values, authenticate, accepts = () => true) {
1973
1973
  }
1974
1974
  //#endregion
1975
1975
  //#region src/auth-failure-log.ts
1976
- const LOG_INTERVAL_MS$2 = 3e4;
1976
+ const LOG_INTERVAL_MS$3 = 3e4;
1977
1977
  const MAX_LOGGED_ORIGIN_LENGTH = 512;
1978
1978
  function requestPathCategory(path) {
1979
1979
  switch (path) {
@@ -2070,7 +2070,7 @@ var AuthFailureRecorder = class {
2070
2070
  return;
2071
2071
  }
2072
2072
  const suppressed = window.suppressed;
2073
- window.nextLogAt = now + LOG_INTERVAL_MS$2;
2073
+ window.nextLogAt = now + LOG_INTERVAL_MS$3;
2074
2074
  window.suppressed = 0;
2075
2075
  try {
2076
2076
  this.observe({
@@ -2124,7 +2124,7 @@ function classifyHttpParserFailure(nodeCode) {
2124
2124
  parserCode
2125
2125
  };
2126
2126
  }
2127
- const LOG_INTERVAL_MS$1 = 3e4;
2127
+ const LOG_INTERVAL_MS$2 = 3e4;
2128
2128
  /**
2129
2129
  * Per-Gateway diagnostics with three fixed sampling slots and no timers or request-selected keys.
2130
2130
  * Each kind records once immediately, then at most once per 30 seconds with suppressed counts.
@@ -2193,7 +2193,7 @@ var HttpFailureRecorder = class {
2193
2193
  return;
2194
2194
  }
2195
2195
  const suppressed = window.suppressed;
2196
- window.nextLogAt = now + LOG_INTERVAL_MS$1;
2196
+ window.nextLogAt = now + LOG_INTERVAL_MS$2;
2197
2197
  window.suppressed = 0;
2198
2198
  try {
2199
2199
  this.observe({
@@ -2210,6 +2210,114 @@ var HttpFailureRecorder = class {
2210
2210
  }
2211
2211
  };
2212
2212
  //#endregion
2213
+ //#region src/api-latency-log.ts
2214
+ /**
2215
+ * Bounded API latency samples for diagnosing slow mobile requests.
2216
+ *
2217
+ * A sample never carries a query string, request body, header or credential.
2218
+ * The path collapses to its first `/api/` segment, so the recorded key set is
2219
+ * limited to fixed route categories rather than session or turn identifiers, and the
2220
+ * same report stays comparable across devices and releases.
2221
+ */
2222
+ /** Only methods with a fixed spelling are recorded; anything else collapses. */
2223
+ const BOUNDED_METHODS = /* @__PURE__ */ new Set([
2224
+ "GET",
2225
+ "HEAD",
2226
+ "POST",
2227
+ "PUT",
2228
+ "PATCH",
2229
+ "DELETE"
2230
+ ]);
2231
+ /** Requests below this budget are ordinary traffic and stay out of the log. */
2232
+ const API_LATENCY_MIN_MS = 3e3;
2233
+ const LOG_INTERVAL_MS$1 = 3e4;
2234
+ const ROUTE_SEGMENTS = Object.freeze([
2235
+ "session",
2236
+ "sessions",
2237
+ "workspace",
2238
+ "workspaces",
2239
+ "settings",
2240
+ "llm",
2241
+ "remote",
2242
+ "mobile-access",
2243
+ "plugins",
2244
+ "plugin-manager",
2245
+ "interactions",
2246
+ "credentials",
2247
+ "auth"
2248
+ ]);
2249
+ /**
2250
+ * Reduce a request target to a bounded diagnostic route.
2251
+ * @param url - The raw request target; its query string is ignored entirely.
2252
+ * @returns `/api/<category>` for a fixed category, `/api/other` for an
2253
+ * unrecognized one, or `undefined` when the target is not an API request.
2254
+ */
2255
+ function boundedApiPath(url) {
2256
+ const pathname = url?.split("?", 1)[0] ?? "";
2257
+ if (!pathname.startsWith("/api/")) return void 0;
2258
+ const category = (pathname.slice(5).split("/", 1)[0] ?? "").toLowerCase();
2259
+ return ROUTE_SEGMENTS.includes(category) ? `/api/${category}` : "/api/other";
2260
+ }
2261
+ /**
2262
+ * Per-Gateway slow-request diagnostics.
2263
+ *
2264
+ * Records once immediately, then at most once per 30 seconds with a suppressed
2265
+ * count, mirroring the HTTP failure recorder. A sink failure cannot change a
2266
+ * response, retry a write or expose the diagnostic sink's error.
2267
+ */
2268
+ var ApiLatencyRecorder = class {
2269
+ observe;
2270
+ now;
2271
+ minDurationMs;
2272
+ reportObserverFailure;
2273
+ nextLogAt = 0;
2274
+ suppressed = 0;
2275
+ observerFailureReported = false;
2276
+ constructor(observe, now = () => performance.now(), minDurationMs = API_LATENCY_MIN_MS, reportObserverFailure = () => {
2277
+ process.emitWarning("Mobile access latency diagnostics could not be recorded", { code: "DSH_MOBILE_LATENCY_LOG_FAILED" });
2278
+ }) {
2279
+ this.observe = observe;
2280
+ this.now = now;
2281
+ this.minDurationMs = minDurationMs;
2282
+ this.reportObserverFailure = reportObserverFailure;
2283
+ }
2284
+ /**
2285
+ * Record one completed request when it exceeded the reporting budget.
2286
+ * @param sample - The bounded route, method, status and measured duration.
2287
+ */
2288
+ record(sample) {
2289
+ if (this.observe === void 0) return;
2290
+ if (!Number.isFinite(sample.durationMs) || sample.durationMs < this.minDurationMs) return;
2291
+ const now = this.now();
2292
+ if (now < this.nextLogAt) {
2293
+ this.suppressed = Math.min(Number.MAX_SAFE_INTEGER, this.suppressed + 1);
2294
+ return;
2295
+ }
2296
+ const suppressed = this.suppressed;
2297
+ this.nextLogAt = now + LOG_INTERVAL_MS$1;
2298
+ this.suppressed = 0;
2299
+ const method = sample.method?.toUpperCase() ?? "";
2300
+ const status = Number.isInteger(sample.status) && sample.status >= 100 && sample.status <= 599 ? sample.status : 0;
2301
+ const entry = {
2302
+ kind: "api",
2303
+ method: BOUNDED_METHODS.has(method) ? method : "OTHER",
2304
+ path: sample.path,
2305
+ status,
2306
+ durationMs: Math.round(sample.durationMs),
2307
+ ...suppressed === 0 ? {} : { suppressed }
2308
+ };
2309
+ try {
2310
+ this.observe(entry);
2311
+ } catch (observerError) {
2312
+ if (this.observerFailureReported) return;
2313
+ this.observerFailureReported = true;
2314
+ try {
2315
+ this.reportObserverFailure();
2316
+ } catch (reporterError) {}
2317
+ }
2318
+ }
2319
+ };
2320
+ //#endregion
2213
2321
  //#region src/upstream-auth-log.ts
2214
2322
  const LOG_INTERVAL_MS = 3e4;
2215
2323
  /** Two fixed sampling slots keep a first failure visible even after a successful exchange. */
@@ -2928,6 +3036,81 @@ async function listComputerImages(path, signal) {
2928
3036
  truncated
2929
3037
  });
2930
3038
  }
3039
+ /**
3040
+ * Resolve a workspace path without a relative or current-drive fallback.
3041
+ *
3042
+ * @param path - Fully qualified directory, or null/empty for the Host home.
3043
+ * @param platform - Host path syntax; explicit for cross-platform parser tests.
3044
+ * @returns The normalized fully qualified path in the selected syntax.
3045
+ */
3046
+ function resolveWorkspaceDirectoryPath(path, platform = process.platform) {
3047
+ if (path === null || path === "") return homedir();
3048
+ const paths = platform === "win32" ? win32 : posix;
3049
+ const qualified = platform !== "win32" || /^(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/]+[\\/]+[^\\/]+(?:[\\/]|$))/u.test(path) && !/^[\\/]{2}[?.][\\/]/u.test(path);
3050
+ if (path.includes("\0") || !paths.isAbsolute(path) || !qualified) throw new HttpError(400, "bad_path");
3051
+ return paths.resolve(path);
3052
+ }
3053
+ /**
3054
+ * List folders only for the dedicated mobile workspace picker.
3055
+ *
3056
+ * Returns at most 500 enterable folders, including directory links. A truncated
3057
+ * level retains its first filesystem-enumerated rows; callers can enter an
3058
+ * absolute path to reach an omitted row. Cancellation preserves its reason;
3059
+ * pending filesystem operations and handle closure settle before this request
3060
+ * releases its Gateway work slot.
3061
+ */
3062
+ async function listWorkspaceDirectories(path, signal) {
3063
+ signal?.throwIfAborted();
3064
+ const target = resolveWorkspaceDirectoryPath(path);
3065
+ const rows = [];
3066
+ let truncated = false;
3067
+ let directory;
3068
+ try {
3069
+ directory = await opendir(target);
3070
+ for (;;) {
3071
+ signal?.throwIfAborted();
3072
+ const entry = await directory.read();
3073
+ signal?.throwIfAborted();
3074
+ if (entry === null) break;
3075
+ const entryPath = resolve(target, entry.name);
3076
+ let enterable = entry.isDirectory();
3077
+ if (!enterable && entry.isSymbolicLink()) try {
3078
+ enterable = (await stat(entryPath)).isDirectory();
3079
+ signal?.throwIfAborted();
3080
+ } catch {
3081
+ signal?.throwIfAborted();
3082
+ continue;
3083
+ }
3084
+ if (!enterable) continue;
3085
+ if (rows.length === MAX_ENTRIES) {
3086
+ truncated = true;
3087
+ break;
3088
+ }
3089
+ rows.push({
3090
+ name: entry.name,
3091
+ path: entryPath
3092
+ });
3093
+ }
3094
+ } catch (error) {
3095
+ if (signal?.aborted) throw signal.reason;
3096
+ throw new HttpError(404, "directory_unavailable");
3097
+ } finally {
3098
+ try {
3099
+ await directory?.close();
3100
+ } catch {
3101
+ throw new HttpError(500, "directory_cleanup_failed");
3102
+ }
3103
+ }
3104
+ signal?.throwIfAborted();
3105
+ rows.sort((left, right) => left.name.localeCompare(right.name));
3106
+ const parent = dirname(target);
3107
+ return Object.freeze({
3108
+ path: target,
3109
+ ...parent === target ? {} : { parent },
3110
+ entries: Object.freeze(rows),
3111
+ truncated
3112
+ });
3113
+ }
2931
3114
  /** Read one bounded regular image file selected by an authenticated device. */
2932
3115
  async function readComputerImage(path, signal) {
2933
3116
  signal?.throwIfAborted();
@@ -4945,21 +5128,43 @@ const MOBILE_DOCUMENT_VIEW_HEADER = "x-dsh-mobile-view";
4945
5128
  const MAX_MOBILE_BOOT_BATCH_BYTES = 33554432;
4946
5129
  const MAX_MOBILE_BOOT_ENTRY_BYTES = 8388608;
4947
5130
  /**
4948
- * Per-merged-batch byte budget. One upstream application batch can carry dozens
4949
- * of client bundles whose combined size exceeds the hard batch cap; the layout
4950
- * batch is therefore chunked into multiple merged batches, each kept safely
4951
- * below {@link MAX_MOBILE_BOOT_BATCH_BYTES} so assembly can never reject it.
5131
+ * Target source-byte size for merged batches. A module larger than this target
5132
+ * remains whole, subject to the separate per-entry and assembly limits. Excess
5133
+ * merged batches use the original individual module URLs instead of rejecting
5134
+ * an otherwise loadable graph. Smaller responses do not guarantee a page-load
5135
+ * deadline: transfer time also depends on the complete graph and connection.
4952
5136
  */
4953
- const MOBILE_BOOT_CHUNK_BYTES = 16777216;
5137
+ const MOBILE_BOOT_CHUNK_BYTES = 3145728;
5138
+ /** Bodies below this size cost more to gzip than they save on the wire. */
5139
+ const MIN_COMPRESSIBLE_DOCUMENT_BYTES = 1024;
4954
5140
  /** Bytes the assembly emits after each entry (`body` plus `\n;\n`). */
4955
5141
  const MOBILE_BOOT_SEPARATOR_BYTES = 3;
4956
5142
  /** Freshness window for cached upstream bundle byte sizes. */
5143
+ /** Catalog sizes change only on a plugin rebuild or host restart. */
5144
+ const CLIENT_MODULE_SIZE_TTL_MS = 6e5;
5145
+ /** Explicit metadata measurement is optional and must fit one short UI operation. */
5146
+ const CLIENT_MODULE_MEASUREMENT_BUDGET_MS = 5e3;
4957
5147
  const MOBILE_BOOT_SIZE_CACHE_TTL_MS = 3e4;
4958
5148
  /** Marker header distinguishing size probes from ordinary proxied bundle fetches. */
4959
5149
  const MOBILE_BOOT_SIZE_PROBE_HEADER = "x-dsh-mobile-size-probe";
4960
- const MAX_MOBILE_BOOT_BATCHES = 8;
5150
+ const MAX_MOBILE_BOOT_BATCHES = 16;
4961
5151
  const MOBILE_BOOT_UPSTREAM_ATTEMPTS = 4;
4962
5152
  const MOBILE_BOOT_RETRY_DELAY_MS = 150;
5153
+ /**
5154
+ * Sanitize an installed package's description for the settings panel.
5155
+ *
5156
+ * The text comes from the plugin's own package metadata, not from an upstream
5157
+ * certification, so URLs are dropped and control characters collapse; the panel
5158
+ * keeps the wording as published instead of translating or editorializing it.
5159
+ * @param value - The package.json `description` field, possibly absent.
5160
+ * @returns A single-line description, or `undefined` when nothing usable remains.
5161
+ */
5162
+ function sanitizeModuleDescription(value) {
5163
+ if (typeof value !== "string") return void 0;
5164
+ const text = value.replace(/[\u0000-\u001f\u007f]+/gu, " ").replace(/https?:\/\/\S+/giu, " ").replace(/\s+/gu, " ").trim();
5165
+ if (text === "") return void 0;
5166
+ return text.length <= 200 ? text : `${text.slice(0, 197)}…`;
5167
+ }
4963
5168
  function upstreamPluginBundleUrl(source, upstreamOrigin) {
4964
5169
  if (!source.startsWith("/plugins/") && !source.startsWith("plugins/") || source.includes("#")) return void 0;
4965
5170
  try {
@@ -5320,6 +5525,41 @@ function splitMobileBootBatch(entries, sizes, passThrough) {
5320
5525
  rows
5321
5526
  };
5322
5527
  }
5528
+ /**
5529
+ * Retain the layout replacement while bounding the graph's stored merged batches.
5530
+ * @param splits - Application batches already partitioned against the byte target.
5531
+ * @returns The same module coverage, with overflow served by valid upstream URLs.
5532
+ */
5533
+ function limitMobileBootBatches(splits) {
5534
+ const layoutPlan = splits.flatMap((split) => split.plans).find((plan) => plan.entries.some((entry) => entry.id === MOBILE_LAYOUT_MODULE));
5535
+ let remaining = MAX_MOBILE_BOOT_BATCHES - (layoutPlan === void 0 ? 0 : 1);
5536
+ const upstreamOrigin = new URL("http://dsh-mobile.invalid");
5537
+ return splits.map((split) => {
5538
+ const byPath = new Map(split.plans.map((plan) => [plan.path, plan]));
5539
+ const plans = [];
5540
+ return {
5541
+ plans,
5542
+ rows: split.rows.flatMap((row) => {
5543
+ const plan = byPath.get(row.url);
5544
+ if (plan === void 0) return [row];
5545
+ if (plan === layoutPlan || remaining > 0) {
5546
+ if (plan !== layoutPlan) remaining -= 1;
5547
+ plans.push(plan);
5548
+ return [row];
5549
+ }
5550
+ return plan.entries.map((entry) => {
5551
+ if (upstreamPluginBundleUrl(entry.url, upstreamOrigin) === void 0) throw new Error("upstream DSH mobile boot has no valid module URL for an overflow batch");
5552
+ return {
5553
+ phase: "application",
5554
+ url: entry.url,
5555
+ rev: entry.rev,
5556
+ entries: [entry.id]
5557
+ };
5558
+ });
5559
+ })
5560
+ };
5561
+ });
5562
+ }
5323
5563
  function escapeMobilePreloadAttribute(value) {
5324
5564
  return value.replaceAll("&", "&amp;").replaceAll("\"", "&quot;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
5325
5565
  }
@@ -5356,11 +5596,11 @@ function projectMobileBootGraph(plan, options) {
5356
5596
  let batches = [];
5357
5597
  let preloadReplacements = /* @__PURE__ */ new Map();
5358
5598
  if (plan.batches !== void 0 && plan.rewrittenBatchEntries !== void 0) {
5359
- const rewrites = plan.rewrittenBatchEntries.map(({ batch, entries }) => ({
5599
+ const splits = limitMobileBootBatches(plan.rewrittenBatchEntries.map(({ entries }) => splitMobileBootBatch(entries, options.sizes ?? /* @__PURE__ */ new Map(), options.passThrough ?? /* @__PURE__ */ new Set())));
5600
+ const rewrites = plan.rewrittenBatchEntries.map(({ batch }, index) => ({
5360
5601
  batch,
5361
- split: splitMobileBootBatch(entries, options.sizes ?? /* @__PURE__ */ new Map(), options.passThrough ?? /* @__PURE__ */ new Set())
5602
+ split: splits[index]
5362
5603
  }));
5363
- 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`);
5364
5604
  for (const { batch, split } of rewrites) {
5365
5605
  const at = plan.batches.indexOf(batch);
5366
5606
  if (at < 0) throw new Error("upstream DSH boot manifest lost an application batch");
@@ -5576,9 +5816,29 @@ function isCompressibleContentType(value) {
5576
5816
  const mediaType = contentType.split(";", 1)[0]?.trim().toLowerCase() ?? "";
5577
5817
  return mediaType.startsWith("text/") || /^(?:application\/(?:javascript|json|xml|x-javascript)|image\/svg\+xml)$/u.test(mediaType);
5578
5818
  }
5819
+ /** Media types the client must receive frame by frame, without gzip blocking. */
5820
+ const INCREMENTAL_MEDIA_TYPES = /* @__PURE__ */ new Set(["text/event-stream"]);
5821
+ /** Bodies below this size cost more to gzip than they save on the wire. */
5822
+ const MIN_COMPRESSIBLE_BYTES = 1024;
5823
+ function responseMediaType(value) {
5824
+ return (Array.isArray(value) ? value[0] : value)?.split(";", 1)[0]?.trim().toLowerCase() ?? "";
5825
+ }
5826
+ /** Static asset paths keep their existing compression contract unchanged. */
5827
+ function isStaticAssetPath(url) {
5828
+ const pathname = url?.split("?", 1)[0] ?? "";
5829
+ return pathname.startsWith("/plugins/") || pathname.startsWith("/assets/");
5830
+ }
5579
5831
  function canCompressResponse(request, response) {
5580
- const pathname = request.url?.split("?", 1)[0] ?? "";
5581
- return (request.method === "GET" || request.method === "HEAD") && (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 && isCompressibleContentType(response.headers["content-type"]);
5832
+ if (!(request.method === "GET" || request.method === "HEAD")) return false;
5833
+ if (response.statusCode !== 200) return false;
5834
+ if (request.headers.range !== void 0) return false;
5835
+ if (response.headers["content-range"] !== void 0) return false;
5836
+ if (response.headers["content-encoding"] !== void 0) return false;
5837
+ if (!isCompressibleContentType(response.headers["content-type"])) return false;
5838
+ if (isStaticAssetPath(request.url)) return true;
5839
+ if (INCREMENTAL_MEDIA_TYPES.has(responseMediaType(response.headers["content-type"]))) return false;
5840
+ const declaredLength = Number(response.headers["content-length"]);
5841
+ return Number.isFinite(declaredLength) && declaredLength >= MIN_COMPRESSIBLE_BYTES;
5582
5842
  }
5583
5843
  function revisionedStaticCacheControl(request, statusCode) {
5584
5844
  if (request.method !== "GET" && request.method !== "HEAD") return void 0;
@@ -5813,6 +6073,7 @@ var MobileAccessGateway = class {
5813
6073
  mobileBootBatches = /* @__PURE__ */ new Map();
5814
6074
  mobileDocumentViews = /* @__PURE__ */ new Map();
5815
6075
  mobileGraphTasks = /* @__PURE__ */ new Set();
6076
+ workspaceDirectoryTasks = /* @__PURE__ */ new Set();
5816
6077
  /** Cached upstream bundle byte sizes (HEAD probes), keyed by resource URL. */
5817
6078
  bootEntrySizeCache = /* @__PURE__ */ new Map();
5818
6079
  extensionEventListeners = /* @__PURE__ */ new Set();
@@ -5840,13 +6101,18 @@ var MobileAccessGateway = class {
5840
6101
  probeDeviceLimiter;
5841
6102
  probeInFlight = /* @__PURE__ */ new Set();
5842
6103
  clientModulePreferences;
6104
+ clientModuleSizeCache = /* @__PURE__ */ new Map();
6105
+ clientModuleDescriptionCache = /* @__PURE__ */ new Map();
6106
+ clientModuleMetadataRevision;
6107
+ clientModuleMeasurementActive = false;
5843
6108
  clientModuleOperations = /* @__PURE__ */ new Set();
5844
6109
  clientModuleTasks = /* @__PURE__ */ new Set();
5845
6110
  clientModuleRevocations = /* @__PURE__ */ new Set();
5846
6111
  authFailures;
5847
6112
  httpFailures;
6113
+ apiLatency;
5848
6114
  upstreamAuthDiagnostics;
5849
- constructor(config, store, extensions, upstreamAuthenticatedUrl, extraWebSocketPaths, blockedUpgradeLog, onDiscoveryDegraded, clientModulePreferences, authFailureLog, httpFailureLog, upstreamAuthLog) {
6115
+ constructor(config, store, extensions, upstreamAuthenticatedUrl, extraWebSocketPaths, blockedUpgradeLog, onDiscoveryDegraded, clientModulePreferences, authFailureLog, httpFailureLog, upstreamAuthLog, apiLatencyLog) {
5850
6116
  this.config = config;
5851
6117
  this.extensions = extensions;
5852
6118
  this.upstreamAuthenticatedUrl = upstreamAuthenticatedUrl;
@@ -5855,6 +6121,7 @@ var MobileAccessGateway = class {
5855
6121
  this.onDiscoveryDegraded = onDiscoveryDegraded;
5856
6122
  this.authFailures = new AuthFailureRecorder(authFailureLog);
5857
6123
  this.httpFailures = new HttpFailureRecorder(httpFailureLog);
6124
+ this.apiLatency = new ApiLatencyRecorder(apiLatencyLog);
5858
6125
  this.upstreamAuthDiagnostics = new UpstreamAuthRecorder(upstreamAuthLog);
5859
6126
  this.clientModulePreferences = clientModulePreferences ?? new ClientModulePreferenceStore(join(dirname(config.stateFile), `${basename(config.stateFile)}.client-modules.json`), config.excludedClientModules);
5860
6127
  this.listenerTlsEnabled = config.tls.mode === "provided";
@@ -6468,6 +6735,7 @@ var MobileAccessGateway = class {
6468
6735
  }
6469
6736
  const computerImages = request.method === "GET" && target.decodedPathname === `/mobile-access/computer-images`;
6470
6737
  const computerImage = request.method === "GET" && target.decodedPathname === `/mobile-access/computer-image`;
6738
+ const workspaceDirectories = request.method === "GET" && target.decodedPathname === `/mobile-access/workspace-directories`;
6471
6739
  const clientModules = target.decodedPathname === `${AUTH_PREFIX}/client-modules`;
6472
6740
  const requestedExtension = extensionTarget(target.decodedPathname);
6473
6741
  const requestedMobileBootBatch = mobileBootBatchKey(target.decodedPathname);
@@ -6488,7 +6756,7 @@ var MobileAccessGateway = class {
6488
6756
  contentType: "text/javascript; charset=utf-8",
6489
6757
  fallback: void 0
6490
6758
  } : void 0 : void 0;
6491
- if (customAsset === void 0 && requestedMobileBootBatch === void 0 && !computerImages && !computerImage && !clientModules && extensionTarget(target.decodedPathname) === void 0 && (target.decodedPathname === "/mobile-access" || target.decodedPathname.startsWith(`/mobile-access/`))) throw new HttpError(404, "not_found");
6759
+ if (customAsset === void 0 && requestedMobileBootBatch === void 0 && !computerImages && !computerImage && !workspaceDirectories && !clientModules && extensionTarget(target.decodedPathname) === void 0 && (target.decodedPathname === "/mobile-access" || target.decodedPathname.startsWith(`/mobile-access/`))) throw new HttpError(404, "not_found");
6492
6760
  if (![
6493
6761
  "GET",
6494
6762
  "HEAD",
@@ -6542,6 +6810,7 @@ var MobileAccessGateway = class {
6542
6810
  const keys = Object.keys(body);
6543
6811
  if (keys.length !== 1) throw new HttpError(400, "bad_request");
6544
6812
  if (keys[0] === "reset" && body.reset === true) kind = "reset";
6813
+ else if (keys[0] === "measure" && body.measure === true) kind = "measure";
6545
6814
  else if (keys[0] === "excludedClientModules") {
6546
6815
  kind = "configure";
6547
6816
  excluded = body.excludedClientModules;
@@ -6588,16 +6857,17 @@ var MobileAccessGateway = class {
6588
6857
  body = Buffer.from(customAsset.fallback);
6589
6858
  }
6590
6859
  if (body.byteLength > 262144) throw new HttpError(413, "payload_too_large");
6860
+ operation.signal.throwIfAborted();
6591
6861
  const etag = `"${createHash("sha256").update(body).digest("hex")}"`;
6592
6862
  const responseHeaders = { ETag: etag };
6593
6863
  if (mtime !== void 0) responseHeaders["Last-Modified"] = mtime.toUTCString();
6864
+ setSecurityHeaders(response, this.tlsEnabled);
6865
+ response.setHeader("Cache-Control", "private, no-cache");
6594
6866
  if (matchesIfNoneMatch(headerValue(request.headers, "if-none-match"), etag)) {
6595
- setSecurityHeaders(response, this.tlsEnabled);
6596
6867
  response.writeHead(304, responseHeaders);
6597
6868
  response.end();
6598
6869
  return;
6599
6870
  }
6600
- setSecurityHeaders(response, this.tlsEnabled);
6601
6871
  response.writeHead(200, {
6602
6872
  ...responseHeaders,
6603
6873
  "Content-Type": customAsset.contentType,
@@ -6620,6 +6890,41 @@ var MobileAccessGateway = class {
6620
6890
  operation.release();
6621
6891
  }
6622
6892
  }
6893
+ if (workspaceDirectories) {
6894
+ const operation = this.allocateRequest(authorization, response, {});
6895
+ const onClose = () => {
6896
+ operation.abort();
6897
+ };
6898
+ response.once("close", onClose);
6899
+ const task = (async () => {
6900
+ try {
6901
+ const query = new URL(target.raw, this.address().origin).searchParams;
6902
+ const listing = await listWorkspaceDirectories(query.get("path"), operation.signal);
6903
+ operation.signal.throwIfAborted();
6904
+ sendJson(response, 200, listing, this.tlsEnabled);
6905
+ } catch (error) {
6906
+ if (operation.signal.aborted && error === operation.signal.reason) return;
6907
+ throw error;
6908
+ } finally {
6909
+ response.removeListener("close", onClose);
6910
+ operation.release();
6911
+ }
6912
+ })();
6913
+ const completion = task.then(() => ({
6914
+ status: "fulfilled",
6915
+ value: void 0
6916
+ }), (reason) => ({
6917
+ status: "rejected",
6918
+ reason
6919
+ }));
6920
+ this.workspaceDirectoryTasks.add(completion);
6921
+ try {
6922
+ await task;
6923
+ } finally {
6924
+ this.workspaceDirectoryTasks.delete(completion);
6925
+ }
6926
+ return;
6927
+ }
6623
6928
  if (computerImage) {
6624
6929
  const operation = this.allocateRequest(authorization, response, {});
6625
6930
  try {
@@ -6967,6 +7272,14 @@ var MobileAccessGateway = class {
6967
7272
  describeClientModules(deviceId, callerSignal) {
6968
7273
  return this.clientModulePreferenceOperation("describe", void 0, deviceId, callerSignal);
6969
7274
  }
7275
+ /** Measure optional catalog metadata without changing saved module selections.
7276
+ * @param deviceId - Authenticated device identity, or computer defaults when omitted.
7277
+ * @param callerSignal - Cancels measurement and drains its admitted requests.
7278
+ * @returns Current selections with the measurements completed within the budget.
7279
+ */
7280
+ measureClientModules(deviceId, callerSignal) {
7281
+ return this.clientModulePreferenceOperation("measure", void 0, deviceId, callerSignal);
7282
+ }
6970
7283
  /** Persist a validated device override or computer default.
6971
7284
  * @param excluded - Untrusted module IDs to validate against the current boot graph.
6972
7285
  * @param deviceId - Authenticated device identity; omitted for computer defaults.
@@ -6984,9 +7297,192 @@ var MobileAccessGateway = class {
6984
7297
  resetClientModules(deviceId, callerSignal) {
6985
7298
  return this.clientModulePreferenceOperation("reset", void 0, deviceId, callerSignal);
6986
7299
  }
7300
+ /** Return cached metadata only; catalog reads and saves never start bundle probes. */
7301
+ describeMobileModulesWithSizes(html) {
7302
+ const plan = parseMobileBootPlan(html);
7303
+ if (this.clientModuleMetadataRevision !== plan.parsed.rev) {
7304
+ this.clientModuleMetadataRevision = plan.parsed.rev;
7305
+ this.clientModuleSizeCache.clear();
7306
+ this.clientModuleDescriptionCache.clear();
7307
+ }
7308
+ const urls = new Map(plan.entries.map((entry) => [entry.id, entry.url]));
7309
+ const now = Date.now();
7310
+ return Object.freeze(describeMobileClientModules(html).map((entry) => {
7311
+ const source = urls.get(entry.id);
7312
+ const size = this.clientModuleSizeCache.get(source);
7313
+ const description = this.clientModuleDescriptionCache.get(`${entry.id}\0${source}`);
7314
+ return Object.freeze({
7315
+ ...entry,
7316
+ ...size === void 0 || now - size.at >= CLIENT_MODULE_SIZE_TTL_MS ? {} : {
7317
+ bytes: size.bytes,
7318
+ gzipBytes: size.gzipBytes
7319
+ },
7320
+ ...description === void 0 || now - description.at >= CLIENT_MODULE_SIZE_TTL_MS ? {} : { description: description.description }
7321
+ });
7322
+ }));
7323
+ }
7324
+ /** Refresh metadata only after an explicit user action, with bounded concurrency. */
7325
+ async measureMobileModules(html, signal) {
7326
+ this.describeMobileModulesWithSizes(html);
7327
+ const queue = [...parseMobileBootPlan(html).entries].sort((left, right) => Number(right.id === MOBILE_LAYOUT_MODULE) - Number(left.id === MOBILE_LAYOUT_MODULE));
7328
+ let cursor = 0;
7329
+ const worker = async () => {
7330
+ while (cursor < queue.length) {
7331
+ signal.throwIfAborted();
7332
+ const entry = queue[cursor++];
7333
+ await this.upstreamBundleMeasurement(entry.url, signal).catch((error) => {
7334
+ if (signal.aborted) throw error;
7335
+ });
7336
+ signal.throwIfAborted();
7337
+ await this.installedModuleDescription(entry.id, entry.url, signal);
7338
+ }
7339
+ };
7340
+ await Promise.allSettled(Array.from({ length: Math.min(4, queue.length) }, worker));
7341
+ signal.throwIfAborted();
7342
+ }
7343
+ /**
7344
+ * Read the installed package description for one catalog module.
7345
+ *
7346
+ * This is best-effort package metadata lookup from the state directory's Node
7347
+ * resolution context and this plugin's dependencies. Unexported or unavailable
7348
+ * package metadata stays unknown; arbitrary paths and subpaths are not read.
7349
+ */
7350
+ async installedModuleDescription(id, source, signal) {
7351
+ if (!/^(?:@[a-z\d][a-z\d._-]*\/)?[a-z\d][a-z\d._-]*$/iu.test(id)) return;
7352
+ const key = `${id}\0${source}`;
7353
+ this.clientModuleDescriptionCache.delete(key);
7354
+ const resolvers = [createRequire(join(dirname(this.config.stateFile), "package.json")), createRequire(import.meta.url)];
7355
+ for (const resolver of resolvers) {
7356
+ signal.throwIfAborted();
7357
+ try {
7358
+ const file = resolver.resolve(`${id}/package.json`);
7359
+ const information = await stat(file);
7360
+ if (!information.isFile() || information.size > 262144) continue;
7361
+ const raw = await readFile(file, {
7362
+ encoding: "utf8",
7363
+ signal
7364
+ });
7365
+ const description = sanitizeModuleDescription(JSON.parse(raw).description);
7366
+ signal.throwIfAborted();
7367
+ if (description !== void 0) {
7368
+ this.clientModuleDescriptionCache.set(key, {
7369
+ at: Date.now(),
7370
+ description
7371
+ });
7372
+ return;
7373
+ }
7374
+ } catch (error) {
7375
+ if (signal.aborted) throw error;
7376
+ }
7377
+ }
7378
+ }
7379
+ /**
7380
+ * Measure one upstream bundle's source and gzipped size.
7381
+ *
7382
+ * Measurements are cached for ten minutes: the settings panel is opened rarely
7383
+ * and upstream bundle bytes only change on a plugin rebuild or host restart.
7384
+ */
7385
+ async upstreamBundleMeasurement(source, signal) {
7386
+ signal.throwIfAborted();
7387
+ this.clientModuleSizeCache.delete(source);
7388
+ if (source === MOBILE_LAYOUT_PATH) {
7389
+ const information = await stat(this.config.mobileLayoutFile);
7390
+ if (!information.isFile() || information.size > MAX_MOBILE_BOOT_ENTRY_BYTES) return void 0;
7391
+ const body = await readFile(this.config.mobileLayoutFile, { signal });
7392
+ if (body.byteLength > MAX_MOBILE_BOOT_ENTRY_BYTES) return void 0;
7393
+ const gzipBytes = (await gzipBuffer(body)).byteLength;
7394
+ signal.throwIfAborted();
7395
+ this.clientModuleSizeCache.set(source, {
7396
+ at: Date.now(),
7397
+ bytes: body.byteLength,
7398
+ gzipBytes
7399
+ });
7400
+ return {
7401
+ bytes: body.byteLength,
7402
+ gzipBytes
7403
+ };
7404
+ }
7405
+ const target = upstreamPluginBundleUrl(source, this.config.upstreamOrigin);
7406
+ if (target === void 0) return void 0;
7407
+ let upstreamRequest;
7408
+ let upstreamClosed;
7409
+ const abort = () => {
7410
+ upstreamRequest?.destroy(requestAbortedError());
7411
+ };
7412
+ signal.addEventListener("abort", abort, { once: true });
7413
+ try {
7414
+ const upstreamCookie = await waitForRequestTask(this.upstreamCookieHeader(), signal);
7415
+ signal.throwIfAborted();
7416
+ const proxied = await new Promise((resolve, reject) => {
7417
+ upstreamRequest = request({
7418
+ protocol: "http:",
7419
+ hostname: stripIpv6Brackets(this.config.upstreamOrigin.hostname),
7420
+ port: Number(this.config.upstreamOrigin.port),
7421
+ method: "GET",
7422
+ path: `${target.pathname}${target.search}`,
7423
+ headers: {
7424
+ host: this.config.upstreamOrigin.host,
7425
+ accept: "text/javascript",
7426
+ "accept-encoding": "identity",
7427
+ [MOBILE_BOOT_SIZE_PROBE_HEADER]: "1",
7428
+ ...upstreamCookie === void 0 ? {} : { cookie: upstreamCookie }
7429
+ },
7430
+ agent: false
7431
+ });
7432
+ const opened = upstreamRequest;
7433
+ upstreamClosed = new Promise((closed) => {
7434
+ opened.once("close", () => {
7435
+ closed();
7436
+ });
7437
+ });
7438
+ upstreamRequest.setTimeout(this.config.upstreamTimeoutMs, () => {
7439
+ upstreamRequest?.destroy(upstreamTimeoutError());
7440
+ });
7441
+ upstreamRequest.once("response", resolve);
7442
+ upstreamRequest.once("error", reject);
7443
+ upstreamRequest.end();
7444
+ });
7445
+ if (proxied.statusCode !== 200) {
7446
+ proxied.resume();
7447
+ return;
7448
+ }
7449
+ const chunks = [];
7450
+ let bytes = 0;
7451
+ for await (const chunk of proxied) {
7452
+ signal.throwIfAborted();
7453
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
7454
+ bytes += buffer.byteLength;
7455
+ if (bytes > MAX_MOBILE_BOOT_ENTRY_BYTES) return void 0;
7456
+ chunks.push(buffer);
7457
+ }
7458
+ const body = Buffer.concat(chunks);
7459
+ const gzipBytes = (await gzipBuffer(body)).byteLength;
7460
+ signal.throwIfAborted();
7461
+ this.clientModuleSizeCache.set(source, {
7462
+ at: Date.now(),
7463
+ bytes: body.byteLength,
7464
+ gzipBytes
7465
+ });
7466
+ return {
7467
+ bytes: body.byteLength,
7468
+ gzipBytes
7469
+ };
7470
+ } finally {
7471
+ signal.removeEventListener("abort", abort);
7472
+ upstreamRequest?.destroy();
7473
+ await upstreamClosed;
7474
+ }
7475
+ }
6987
7476
  async clientModulePreferenceOperation(kind, excluded, deviceId, callerSignal) {
6988
7477
  if (this.closing || !this.started) throw new HttpError(503, "unavailable");
7478
+ if (kind === "measure" && this.clientModuleMeasurementActive) throw new HttpError(429, "busy");
7479
+ if (kind === "measure") this.clientModuleMeasurementActive = true;
6989
7480
  const controller = new AbortController();
7481
+ let measurementExpired = false;
7482
+ const measurementTimer = kind === "measure" ? setTimeout(() => {
7483
+ measurementExpired = true;
7484
+ controller.abort();
7485
+ }, CLIENT_MODULE_MEASUREMENT_BUDGET_MS) : void 0;
6990
7486
  const abort = () => controller.abort();
6991
7487
  if (callerSignal?.aborted === true) controller.abort();
6992
7488
  callerSignal?.addEventListener("abort", abort, { once: true });
@@ -7020,8 +7516,13 @@ var MobileAccessGateway = class {
7020
7516
  controller.signal.throwIfAborted();
7021
7517
  try {
7022
7518
  const plan = parseMobileBootPlan(html);
7519
+ if (kind === "measure") try {
7520
+ await this.measureMobileModules(html, controller.signal);
7521
+ } catch (error) {
7522
+ if (!measurementExpired || callerSignal?.aborted === true || this.closing) throw error;
7523
+ }
7023
7524
  return Object.freeze({
7024
- entries: describeMobileClientModules(html),
7525
+ entries: this.describeMobileModulesWithSizes(html),
7025
7526
  ...selection,
7026
7527
  ...plan.batches === void 0 ? {
7027
7528
  excludedClientModules: Object.freeze([]),
@@ -7038,6 +7539,8 @@ var MobileAccessGateway = class {
7038
7539
  try {
7039
7540
  return await task;
7040
7541
  } finally {
7542
+ clearTimeout(measurementTimer);
7543
+ if (kind === "measure") this.clientModuleMeasurementActive = false;
7041
7544
  callerSignal?.removeEventListener("abort", abort);
7042
7545
  this.clientModuleOperations.delete(controller);
7043
7546
  this.clientModuleTasks.delete(task);
@@ -7169,12 +7672,18 @@ var MobileAccessGateway = class {
7169
7672
  delete headers["content-encoding"];
7170
7673
  delete headers.etag;
7171
7674
  setSecurityHeaders(response, this.tlsEnabled, "proxied");
7172
- response.writeHead(200, {
7675
+ const documentGzip = body.byteLength >= MIN_COMPRESSIBLE_DOCUMENT_BYTES && acceptsGzip(request.headers["accept-encoding"]);
7676
+ const payload = documentGzip ? await gzipBuffer(body) : body;
7677
+ operation.signal.throwIfAborted();
7678
+ const documentHeaders = {
7173
7679
  ...headers,
7174
7680
  "Content-Type": "text/html; charset=utf-8",
7175
- "Content-Length": body.byteLength
7176
- });
7177
- response.end(body);
7681
+ "Content-Length": payload.byteLength
7682
+ };
7683
+ addVaryAcceptEncoding(documentHeaders);
7684
+ if (documentGzip) documentHeaders["Content-Encoding"] = "gzip";
7685
+ response.writeHead(200, documentHeaders);
7686
+ response.end(payload);
7178
7687
  } catch (error) {
7179
7688
  holder.request?.destroy();
7180
7689
  if (error instanceof HttpError) throw error;
@@ -7372,7 +7881,7 @@ var MobileAccessGateway = class {
7372
7881
  const headers = {
7373
7882
  "Content-Type": "text/javascript; charset=utf-8",
7374
7883
  "Content-Length": body.byteLength,
7375
- "Cache-Control": "private, no-cache",
7884
+ "Cache-Control": "private, max-age=30, must-revalidate",
7376
7885
  ETag: etag
7377
7886
  };
7378
7887
  if (compressed) headers["Content-Encoding"] = "gzip";
@@ -7381,7 +7890,7 @@ var MobileAccessGateway = class {
7381
7890
  setSecurityHeaders(response, this.tlsEnabled);
7382
7891
  response.writeHead(304, {
7383
7892
  ETag: etag,
7384
- "Cache-Control": "private, no-cache",
7893
+ "Cache-Control": "private, max-age=30, must-revalidate",
7385
7894
  Vary: String(headers.vary)
7386
7895
  });
7387
7896
  response.end();
@@ -7560,6 +8069,16 @@ var MobileAccessGateway = class {
7560
8069
  operation.abort();
7561
8070
  };
7562
8071
  response.once("close", onClose);
8072
+ const startedAt = performance.now();
8073
+ const latencyPath = boundedApiPath(request.url);
8074
+ if (latencyPath !== void 0) response.once("close", () => {
8075
+ this.apiLatency.record({
8076
+ method: request.method,
8077
+ path: latencyPath,
8078
+ status: response.headersSent ? response.statusCode : 0,
8079
+ durationMs: performance.now() - startedAt
8080
+ });
8081
+ });
7563
8082
  if (replayable) request.resume();
7564
8083
  try {
7565
8084
  const attempts = replayable ? MOBILE_BOOT_UPSTREAM_ATTEMPTS : 1;
@@ -8068,6 +8587,8 @@ var MobileAccessGateway = class {
8068
8587
  }
8069
8588
  async performClose() {
8070
8589
  this.closing = true;
8590
+ const failures = [];
8591
+ const directoryDrain = Promise.all(this.workspaceDirectoryTasks);
8071
8592
  for (const controller of this.clientModuleOperations) controller.abort();
8072
8593
  if (this.extensionChangeTimer !== void 0) clearInterval(this.extensionChangeTimer);
8073
8594
  this.extensionChangeTimer = void 0;
@@ -8077,7 +8598,9 @@ var MobileAccessGateway = class {
8077
8598
  this.removeSessionListener();
8078
8599
  this.pendingDeviceRevocations.clear();
8079
8600
  for (const view of this.mobileDocumentViews.values()) this.removeMobileDocumentView(view);
8080
- const accessClose = this.access.close();
8601
+ const accessClose = this.access.close().catch((error) => {
8602
+ failures.push(error);
8603
+ });
8081
8604
  for (const stored of this.mobileBootBatches.values()) stored.assembly?.controller.abort();
8082
8605
  for (const request of this.activeRequests.values()) {
8083
8606
  clearTimeout(request.timer);
@@ -8090,19 +8613,29 @@ var MobileAccessGateway = class {
8090
8613
  for (const socket of this.connectedSockets) socket.destroy();
8091
8614
  if (this.discoveryTimer !== void 0) clearInterval(this.discoveryTimer);
8092
8615
  this.discoveryTimer = void 0;
8093
- await this.closeBonjour();
8616
+ for (const result of await directoryDrain) if (result.status === "rejected") failures.push(result.reason);
8617
+ await this.closeBonjour().catch((error) => {
8618
+ failures.push(error);
8619
+ });
8094
8620
  const discoverySocket = this.discoverySocket;
8095
8621
  this.discoverySocket = void 0;
8096
8622
  if (discoverySocket !== void 0) await new Promise((resolve) => {
8097
8623
  discoverySocket.close(() => resolve());
8624
+ }).catch((error) => {
8625
+ failures.push(error);
8098
8626
  });
8099
8627
  const server = this.server;
8100
8628
  this.server = void 0;
8101
- if (server !== void 0 && server.listening) {
8629
+ if (server !== void 0 && server.listening) try {
8102
8630
  server.closeAllConnections();
8103
- await new Promise((resolve) => {
8104
- server.close(() => resolve());
8631
+ await new Promise((resolve, reject) => {
8632
+ server.close((error) => {
8633
+ if (error) reject(error);
8634
+ else resolve();
8635
+ });
8105
8636
  });
8637
+ } catch (error) {
8638
+ failures.push(error);
8106
8639
  }
8107
8640
  await accessClose;
8108
8641
  await Promise.allSettled(this.clientModuleTasks);
@@ -8113,6 +8646,7 @@ var MobileAccessGateway = class {
8113
8646
  this.connectedSockets.clear();
8114
8647
  this.policy = void 0;
8115
8648
  this.listenerPort = void 0;
8649
+ if (failures.length > 0) throw new AggregateError(failures, "mobile gateway cleanup failed");
8116
8650
  }
8117
8651
  /** Safe metadata helper for direct loopback integrations. */
8118
8652
  devices() {
@@ -18190,6 +18724,8 @@ async function apply(ctx, config) {
18190
18724
  logger.warn("http failure %s", JSON.stringify(entry));
18191
18725
  }, (entry) => {
18192
18726
  logger.info("upstream auth %s", JSON.stringify(entry));
18727
+ }, (entry) => {
18728
+ logger.warn("slow api %s", JSON.stringify(entry));
18193
18729
  });
18194
18730
  await candidate.start();
18195
18731
  lanGateway = candidate;
@@ -18252,6 +18788,8 @@ async function apply(ctx, config) {
18252
18788
  logger.warn("http failure %s", JSON.stringify(entry));
18253
18789
  }, (entry) => {
18254
18790
  logger.info("upstream auth %s", JSON.stringify(entry));
18791
+ }, (entry) => {
18792
+ logger.warn("slow api %s", JSON.stringify(entry));
18255
18793
  });
18256
18794
  await candidate.start();
18257
18795
  return candidate;
@@ -18274,6 +18812,8 @@ async function apply(ctx, config) {
18274
18812
  logger.warn("http failure %s", JSON.stringify(entry));
18275
18813
  }, (entry) => {
18276
18814
  logger.info("upstream auth %s", JSON.stringify(entry));
18815
+ }, (entry) => {
18816
+ logger.warn("slow api %s", JSON.stringify(entry));
18277
18817
  });
18278
18818
  try {
18279
18819
  await candidate.start();
@@ -18291,6 +18831,8 @@ async function apply(ctx, config) {
18291
18831
  logger.warn("http failure %s", JSON.stringify(entry));
18292
18832
  }, (entry) => {
18293
18833
  logger.info("upstream auth %s", JSON.stringify(entry));
18834
+ }, (entry) => {
18835
+ logger.warn("slow api %s", JSON.stringify(entry));
18294
18836
  });
18295
18837
  try {
18296
18838
  await candidate.start();
@@ -18515,8 +19057,8 @@ async function apply(ctx, config) {
18515
19057
  }
18516
19058
  if (request.method !== "POST") throw new HttpError(405, "method_not_allowed");
18517
19059
  const body = await readJsonObject(request, template.maxBodyBytes);
18518
- if (Object.keys(body).length !== 1 || Object.keys(body).some((key) => key !== "reset" && key !== "excludedClientModules")) throw new HttpError(400, "bad_request");
18519
- sendJson(response, 200, body.reset === true ? await gateway.resetClientModules(void 0, operation.signal) : await gateway.configureClientModules(body.excludedClientModules, void 0, operation.signal), false);
19060
+ if (Object.keys(body).length !== 1 || Object.keys(body).some((key) => key !== "reset" && key !== "excludedClientModules" && key !== "measure") || "measure" in body && body.measure !== true) throw new HttpError(400, "bad_request");
19061
+ sendJson(response, 200, body.measure === true ? await gateway.measureClientModules(void 0, operation.signal) : body.reset === true ? await gateway.resetClientModules(void 0, operation.signal) : await gateway.configureClientModules(body.excludedClientModules, void 0, operation.signal), false);
18520
19062
  } catch (error) {
18521
19063
  if (!(error instanceof ClientModuleConflictError)) throw error;
18522
19064
  sendJson(response, 409, {