dsh-mobile 0.6.15 → 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/CHANGELOG.md +25 -0
- package/CONTRIBUTORS.md +24 -2
- package/README.en.md +8 -6
- package/README.md +8 -6
- package/SECURITY.md +1 -0
- package/apps/mobile/README.md +29 -5
- package/apps/mobile/README.zh-CN.md +29 -5
- package/apps/mobile/release.json +3 -3
- package/cordis.patch.yml +10 -17
- package/docs/ATTACH_EXISTING_FRPS.en.md +8 -0
- package/docs/ATTACH_EXISTING_FRPS.md +8 -0
- package/docs/CLIENT_MODULES.en.md +10 -2
- package/docs/CLIENT_MODULES.md +10 -2
- package/docs/SLOW_CONNECTIONS.en.md +11 -1
- package/docs/SLOW_CONNECTIONS.md +11 -1
- package/lib/client.js +1771 -89
- package/lib/client.js.map +1 -1
- package/lib/index.d.mts +54 -1
- package/lib/index.mjs +621 -44
- package/lib/index.mjs.map +1 -1
- package/package.json +8 -2
- package/packages/question-fixes/package.json +1 -1
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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();
|
|
@@ -4911,6 +5094,26 @@ function renderLoginScript(locale) {
|
|
|
4911
5094
|
`;
|
|
4912
5095
|
}
|
|
4913
5096
|
//#endregion
|
|
5097
|
+
//#region src/browser-ui-locale.ts
|
|
5098
|
+
const UI_LOCALE_COOKIE = "dsh_ma_ui_locale";
|
|
5099
|
+
/** Read presentation data only; malformed or ambiguous cookies keep browser negotiation. */
|
|
5100
|
+
function readBrowserUiLocale(cookieHeader) {
|
|
5101
|
+
if (cookieHeader === void 0 || cookieHeader.length > 8192) return void 0;
|
|
5102
|
+
let locale;
|
|
5103
|
+
for (const part of cookieHeader.split(";")) {
|
|
5104
|
+
const separator = part.indexOf("=");
|
|
5105
|
+
if ((separator === -1 ? part : part.slice(0, separator)).trim() !== UI_LOCALE_COOKIE) continue;
|
|
5106
|
+
const value = separator === -1 ? "" : part.slice(separator + 1).trim();
|
|
5107
|
+
if (locale !== void 0 || value !== "en" && value !== "zh" && value !== "it") return void 0;
|
|
5108
|
+
locale = value;
|
|
5109
|
+
}
|
|
5110
|
+
return locale;
|
|
5111
|
+
}
|
|
5112
|
+
/** Prefer the last DSH display language over the browser's default language. */
|
|
5113
|
+
function resolveBrowserUiLocale(cookieHeader, acceptLanguage) {
|
|
5114
|
+
return readBrowserUiLocale(cookieHeader) ?? resolveAuthPageLocale(acceptLanguage);
|
|
5115
|
+
}
|
|
5116
|
+
//#endregion
|
|
4914
5117
|
//#region src/gateway.ts
|
|
4915
5118
|
const MAX_CONTROL_BODY_BYTES = 16384;
|
|
4916
5119
|
const MAX_HEADER_BYTES = 16384;
|
|
@@ -4925,21 +5128,43 @@ const MOBILE_DOCUMENT_VIEW_HEADER = "x-dsh-mobile-view";
|
|
|
4925
5128
|
const MAX_MOBILE_BOOT_BATCH_BYTES = 33554432;
|
|
4926
5129
|
const MAX_MOBILE_BOOT_ENTRY_BYTES = 8388608;
|
|
4927
5130
|
/**
|
|
4928
|
-
*
|
|
4929
|
-
*
|
|
4930
|
-
*
|
|
4931
|
-
*
|
|
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.
|
|
4932
5136
|
*/
|
|
4933
|
-
const MOBILE_BOOT_CHUNK_BYTES =
|
|
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;
|
|
4934
5140
|
/** Bytes the assembly emits after each entry (`body` plus `\n;\n`). */
|
|
4935
5141
|
const MOBILE_BOOT_SEPARATOR_BYTES = 3;
|
|
4936
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;
|
|
4937
5147
|
const MOBILE_BOOT_SIZE_CACHE_TTL_MS = 3e4;
|
|
4938
5148
|
/** Marker header distinguishing size probes from ordinary proxied bundle fetches. */
|
|
4939
5149
|
const MOBILE_BOOT_SIZE_PROBE_HEADER = "x-dsh-mobile-size-probe";
|
|
4940
|
-
const MAX_MOBILE_BOOT_BATCHES =
|
|
5150
|
+
const MAX_MOBILE_BOOT_BATCHES = 16;
|
|
4941
5151
|
const MOBILE_BOOT_UPSTREAM_ATTEMPTS = 4;
|
|
4942
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
|
+
}
|
|
4943
5168
|
function upstreamPluginBundleUrl(source, upstreamOrigin) {
|
|
4944
5169
|
if (!source.startsWith("/plugins/") && !source.startsWith("plugins/") || source.includes("#")) return void 0;
|
|
4945
5170
|
try {
|
|
@@ -5300,6 +5525,41 @@ function splitMobileBootBatch(entries, sizes, passThrough) {
|
|
|
5300
5525
|
rows
|
|
5301
5526
|
};
|
|
5302
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
|
+
}
|
|
5303
5563
|
function escapeMobilePreloadAttribute(value) {
|
|
5304
5564
|
return value.replaceAll("&", "&").replaceAll("\"", """).replaceAll("<", "<").replaceAll(">", ">");
|
|
5305
5565
|
}
|
|
@@ -5336,11 +5596,11 @@ function projectMobileBootGraph(plan, options) {
|
|
|
5336
5596
|
let batches = [];
|
|
5337
5597
|
let preloadReplacements = /* @__PURE__ */ new Map();
|
|
5338
5598
|
if (plan.batches !== void 0 && plan.rewrittenBatchEntries !== void 0) {
|
|
5339
|
-
const
|
|
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) => ({
|
|
5340
5601
|
batch,
|
|
5341
|
-
split:
|
|
5602
|
+
split: splits[index]
|
|
5342
5603
|
}));
|
|
5343
|
-
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`);
|
|
5344
5604
|
for (const { batch, split } of rewrites) {
|
|
5345
5605
|
const at = plan.batches.indexOf(batch);
|
|
5346
5606
|
if (at < 0) throw new Error("upstream DSH boot manifest lost an application batch");
|
|
@@ -5556,9 +5816,29 @@ function isCompressibleContentType(value) {
|
|
|
5556
5816
|
const mediaType = contentType.split(";", 1)[0]?.trim().toLowerCase() ?? "";
|
|
5557
5817
|
return mediaType.startsWith("text/") || /^(?:application\/(?:javascript|json|xml|x-javascript)|image\/svg\+xml)$/u.test(mediaType);
|
|
5558
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
|
+
}
|
|
5559
5831
|
function canCompressResponse(request, response) {
|
|
5560
|
-
|
|
5561
|
-
|
|
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;
|
|
5562
5842
|
}
|
|
5563
5843
|
function revisionedStaticCacheControl(request, statusCode) {
|
|
5564
5844
|
if (request.method !== "GET" && request.method !== "HEAD") return void 0;
|
|
@@ -5793,6 +6073,7 @@ var MobileAccessGateway = class {
|
|
|
5793
6073
|
mobileBootBatches = /* @__PURE__ */ new Map();
|
|
5794
6074
|
mobileDocumentViews = /* @__PURE__ */ new Map();
|
|
5795
6075
|
mobileGraphTasks = /* @__PURE__ */ new Set();
|
|
6076
|
+
workspaceDirectoryTasks = /* @__PURE__ */ new Set();
|
|
5796
6077
|
/** Cached upstream bundle byte sizes (HEAD probes), keyed by resource URL. */
|
|
5797
6078
|
bootEntrySizeCache = /* @__PURE__ */ new Map();
|
|
5798
6079
|
extensionEventListeners = /* @__PURE__ */ new Set();
|
|
@@ -5820,13 +6101,18 @@ var MobileAccessGateway = class {
|
|
|
5820
6101
|
probeDeviceLimiter;
|
|
5821
6102
|
probeInFlight = /* @__PURE__ */ new Set();
|
|
5822
6103
|
clientModulePreferences;
|
|
6104
|
+
clientModuleSizeCache = /* @__PURE__ */ new Map();
|
|
6105
|
+
clientModuleDescriptionCache = /* @__PURE__ */ new Map();
|
|
6106
|
+
clientModuleMetadataRevision;
|
|
6107
|
+
clientModuleMeasurementActive = false;
|
|
5823
6108
|
clientModuleOperations = /* @__PURE__ */ new Set();
|
|
5824
6109
|
clientModuleTasks = /* @__PURE__ */ new Set();
|
|
5825
6110
|
clientModuleRevocations = /* @__PURE__ */ new Set();
|
|
5826
6111
|
authFailures;
|
|
5827
6112
|
httpFailures;
|
|
6113
|
+
apiLatency;
|
|
5828
6114
|
upstreamAuthDiagnostics;
|
|
5829
|
-
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) {
|
|
5830
6116
|
this.config = config;
|
|
5831
6117
|
this.extensions = extensions;
|
|
5832
6118
|
this.upstreamAuthenticatedUrl = upstreamAuthenticatedUrl;
|
|
@@ -5835,6 +6121,7 @@ var MobileAccessGateway = class {
|
|
|
5835
6121
|
this.onDiscoveryDegraded = onDiscoveryDegraded;
|
|
5836
6122
|
this.authFailures = new AuthFailureRecorder(authFailureLog);
|
|
5837
6123
|
this.httpFailures = new HttpFailureRecorder(httpFailureLog);
|
|
6124
|
+
this.apiLatency = new ApiLatencyRecorder(apiLatencyLog);
|
|
5838
6125
|
this.upstreamAuthDiagnostics = new UpstreamAuthRecorder(upstreamAuthLog);
|
|
5839
6126
|
this.clientModulePreferences = clientModulePreferences ?? new ClientModulePreferenceStore(join(dirname(config.stateFile), `${basename(config.stateFile)}.client-modules.json`), config.excludedClientModules);
|
|
5840
6127
|
this.listenerTlsEnabled = config.tls.mode === "provided";
|
|
@@ -6257,12 +6544,17 @@ var MobileAccessGateway = class {
|
|
|
6257
6544
|
nativeResponseInstanceId(result) {
|
|
6258
6545
|
return this.config.pairingCaFile === void 0 ? result.nativeInstanceId ?? this.config.instanceId : this.config.instanceId;
|
|
6259
6546
|
}
|
|
6547
|
+
/** Older Apps require exact wire keys; expose this hint only to explicit opt-in clients after authentication. */
|
|
6548
|
+
nativeResponseInstallationIdentity(request) {
|
|
6549
|
+
return this.config.installationId === void 0 || request.headers["x-dsh-mobile-capabilities"] !== "installation-id" ? {} : { installationId: this.config.installationId };
|
|
6550
|
+
}
|
|
6260
6551
|
async handleNativePair(request, response) {
|
|
6261
6552
|
const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
|
|
6262
6553
|
if (typeof body.token !== "string" || body.label !== void 0 && typeof body.label !== "string") throw new HttpError(400, "bad_request");
|
|
6263
6554
|
const result = await this.access.pair(request.socket.remoteAddress ?? "unknown", body.token, body.label);
|
|
6264
6555
|
sendJson(response, 201, {
|
|
6265
6556
|
instanceId: this.nativeResponseInstanceId(result),
|
|
6557
|
+
...this.nativeResponseInstallationIdentity(request),
|
|
6266
6558
|
deviceId: result.deviceId,
|
|
6267
6559
|
deviceToken: result.deviceToken,
|
|
6268
6560
|
deviceExpiresAt: result.deviceExpiresAt,
|
|
@@ -6283,6 +6575,7 @@ var MobileAccessGateway = class {
|
|
|
6283
6575
|
const result = await this.access.renew(selected.token);
|
|
6284
6576
|
sendJson(response, 200, {
|
|
6285
6577
|
instanceId: this.nativeResponseInstanceId(result),
|
|
6578
|
+
...this.nativeResponseInstallationIdentity(request),
|
|
6286
6579
|
deviceId: result.deviceId,
|
|
6287
6580
|
sessionToken: result.sessionToken,
|
|
6288
6581
|
csrfToken: result.csrfToken,
|
|
@@ -6302,6 +6595,7 @@ var MobileAccessGateway = class {
|
|
|
6302
6595
|
const result = await this.access.probe(selected.token);
|
|
6303
6596
|
sendJson(response, 200, {
|
|
6304
6597
|
instanceId: this.nativeResponseInstanceId(result),
|
|
6598
|
+
...this.nativeResponseInstallationIdentity(request),
|
|
6305
6599
|
deviceId: result.deviceId,
|
|
6306
6600
|
deviceExpiresAt: result.deviceExpiresAt
|
|
6307
6601
|
}, this.tlsEnabled);
|
|
@@ -6390,9 +6684,9 @@ var MobileAccessGateway = class {
|
|
|
6390
6684
|
if (target.search === "" && request.method === "GET" && (target.decodedPathname === `/mobile-access/pair` || target.decodedPathname === `/mobile-access/pair.js`)) {
|
|
6391
6685
|
if (!this.access.pairingStatus().open) throw new HttpError(404, "not_found");
|
|
6392
6686
|
const languageHeader = request.headers["accept-language"];
|
|
6393
|
-
const locale =
|
|
6687
|
+
const locale = resolveBrowserUiLocale(request.headers.cookie, typeof languageHeader === "string" ? languageHeader : void 0);
|
|
6394
6688
|
setSecurityHeaders(response, this.tlsEnabled);
|
|
6395
|
-
response.setHeader("Vary", "Accept-Language");
|
|
6689
|
+
response.setHeader("Vary", "Accept-Language, Cookie");
|
|
6396
6690
|
const body = target.decodedPathname.endsWith(".js") ? renderPairScript(locale) : renderPairPage(locale);
|
|
6397
6691
|
response.writeHead(200, {
|
|
6398
6692
|
"Content-Type": target.decodedPathname.endsWith(".js") ? "text/javascript; charset=utf-8" : "text/html; charset=utf-8",
|
|
@@ -6404,9 +6698,9 @@ var MobileAccessGateway = class {
|
|
|
6404
6698
|
if (request.method === "GET" && (target.decodedPathname === `/mobile-access/login` || target.decodedPathname === `/mobile-access/login.js`)) {
|
|
6405
6699
|
if (target.decodedPathname.endsWith(".js") && target.search !== "") throw new HttpError(400, "bad_request");
|
|
6406
6700
|
const languageHeader = request.headers["accept-language"];
|
|
6407
|
-
const locale =
|
|
6701
|
+
const locale = resolveBrowserUiLocale(request.headers.cookie, typeof languageHeader === "string" ? languageHeader : void 0);
|
|
6408
6702
|
setSecurityHeaders(response, this.tlsEnabled);
|
|
6409
|
-
response.setHeader("Vary", "Accept-Language");
|
|
6703
|
+
response.setHeader("Vary", "Accept-Language, Cookie");
|
|
6410
6704
|
const body = target.decodedPathname.endsWith(".js") ? renderLoginScript(locale) : renderLoginPage(locale);
|
|
6411
6705
|
response.writeHead(200, {
|
|
6412
6706
|
"Content-Type": target.decodedPathname.endsWith(".js") ? "text/javascript; charset=utf-8" : "text/html; charset=utf-8",
|
|
@@ -6441,6 +6735,7 @@ var MobileAccessGateway = class {
|
|
|
6441
6735
|
}
|
|
6442
6736
|
const computerImages = request.method === "GET" && target.decodedPathname === `/mobile-access/computer-images`;
|
|
6443
6737
|
const computerImage = request.method === "GET" && target.decodedPathname === `/mobile-access/computer-image`;
|
|
6738
|
+
const workspaceDirectories = request.method === "GET" && target.decodedPathname === `/mobile-access/workspace-directories`;
|
|
6444
6739
|
const clientModules = target.decodedPathname === `${AUTH_PREFIX}/client-modules`;
|
|
6445
6740
|
const requestedExtension = extensionTarget(target.decodedPathname);
|
|
6446
6741
|
const requestedMobileBootBatch = mobileBootBatchKey(target.decodedPathname);
|
|
@@ -6461,7 +6756,7 @@ var MobileAccessGateway = class {
|
|
|
6461
6756
|
contentType: "text/javascript; charset=utf-8",
|
|
6462
6757
|
fallback: void 0
|
|
6463
6758
|
} : void 0 : void 0;
|
|
6464
|
-
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");
|
|
6465
6760
|
if (![
|
|
6466
6761
|
"GET",
|
|
6467
6762
|
"HEAD",
|
|
@@ -6515,6 +6810,7 @@ var MobileAccessGateway = class {
|
|
|
6515
6810
|
const keys = Object.keys(body);
|
|
6516
6811
|
if (keys.length !== 1) throw new HttpError(400, "bad_request");
|
|
6517
6812
|
if (keys[0] === "reset" && body.reset === true) kind = "reset";
|
|
6813
|
+
else if (keys[0] === "measure" && body.measure === true) kind = "measure";
|
|
6518
6814
|
else if (keys[0] === "excludedClientModules") {
|
|
6519
6815
|
kind = "configure";
|
|
6520
6816
|
excluded = body.excludedClientModules;
|
|
@@ -6561,16 +6857,17 @@ var MobileAccessGateway = class {
|
|
|
6561
6857
|
body = Buffer.from(customAsset.fallback);
|
|
6562
6858
|
}
|
|
6563
6859
|
if (body.byteLength > 262144) throw new HttpError(413, "payload_too_large");
|
|
6860
|
+
operation.signal.throwIfAborted();
|
|
6564
6861
|
const etag = `"${createHash("sha256").update(body).digest("hex")}"`;
|
|
6565
6862
|
const responseHeaders = { ETag: etag };
|
|
6566
6863
|
if (mtime !== void 0) responseHeaders["Last-Modified"] = mtime.toUTCString();
|
|
6864
|
+
setSecurityHeaders(response, this.tlsEnabled);
|
|
6865
|
+
response.setHeader("Cache-Control", "private, no-cache");
|
|
6567
6866
|
if (matchesIfNoneMatch(headerValue(request.headers, "if-none-match"), etag)) {
|
|
6568
|
-
setSecurityHeaders(response, this.tlsEnabled);
|
|
6569
6867
|
response.writeHead(304, responseHeaders);
|
|
6570
6868
|
response.end();
|
|
6571
6869
|
return;
|
|
6572
6870
|
}
|
|
6573
|
-
setSecurityHeaders(response, this.tlsEnabled);
|
|
6574
6871
|
response.writeHead(200, {
|
|
6575
6872
|
...responseHeaders,
|
|
6576
6873
|
"Content-Type": customAsset.contentType,
|
|
@@ -6593,6 +6890,41 @@ var MobileAccessGateway = class {
|
|
|
6593
6890
|
operation.release();
|
|
6594
6891
|
}
|
|
6595
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
|
+
}
|
|
6596
6928
|
if (computerImage) {
|
|
6597
6929
|
const operation = this.allocateRequest(authorization, response, {});
|
|
6598
6930
|
try {
|
|
@@ -6940,6 +7272,14 @@ var MobileAccessGateway = class {
|
|
|
6940
7272
|
describeClientModules(deviceId, callerSignal) {
|
|
6941
7273
|
return this.clientModulePreferenceOperation("describe", void 0, deviceId, callerSignal);
|
|
6942
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
|
+
}
|
|
6943
7283
|
/** Persist a validated device override or computer default.
|
|
6944
7284
|
* @param excluded - Untrusted module IDs to validate against the current boot graph.
|
|
6945
7285
|
* @param deviceId - Authenticated device identity; omitted for computer defaults.
|
|
@@ -6957,9 +7297,192 @@ var MobileAccessGateway = class {
|
|
|
6957
7297
|
resetClientModules(deviceId, callerSignal) {
|
|
6958
7298
|
return this.clientModulePreferenceOperation("reset", void 0, deviceId, callerSignal);
|
|
6959
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
|
+
}
|
|
6960
7476
|
async clientModulePreferenceOperation(kind, excluded, deviceId, callerSignal) {
|
|
6961
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;
|
|
6962
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;
|
|
6963
7486
|
const abort = () => controller.abort();
|
|
6964
7487
|
if (callerSignal?.aborted === true) controller.abort();
|
|
6965
7488
|
callerSignal?.addEventListener("abort", abort, { once: true });
|
|
@@ -6993,8 +7516,13 @@ var MobileAccessGateway = class {
|
|
|
6993
7516
|
controller.signal.throwIfAborted();
|
|
6994
7517
|
try {
|
|
6995
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
|
+
}
|
|
6996
7524
|
return Object.freeze({
|
|
6997
|
-
entries:
|
|
7525
|
+
entries: this.describeMobileModulesWithSizes(html),
|
|
6998
7526
|
...selection,
|
|
6999
7527
|
...plan.batches === void 0 ? {
|
|
7000
7528
|
excludedClientModules: Object.freeze([]),
|
|
@@ -7011,6 +7539,8 @@ var MobileAccessGateway = class {
|
|
|
7011
7539
|
try {
|
|
7012
7540
|
return await task;
|
|
7013
7541
|
} finally {
|
|
7542
|
+
clearTimeout(measurementTimer);
|
|
7543
|
+
if (kind === "measure") this.clientModuleMeasurementActive = false;
|
|
7014
7544
|
callerSignal?.removeEventListener("abort", abort);
|
|
7015
7545
|
this.clientModuleOperations.delete(controller);
|
|
7016
7546
|
this.clientModuleTasks.delete(task);
|
|
@@ -7142,12 +7672,18 @@ var MobileAccessGateway = class {
|
|
|
7142
7672
|
delete headers["content-encoding"];
|
|
7143
7673
|
delete headers.etag;
|
|
7144
7674
|
setSecurityHeaders(response, this.tlsEnabled, "proxied");
|
|
7145
|
-
|
|
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 = {
|
|
7146
7679
|
...headers,
|
|
7147
7680
|
"Content-Type": "text/html; charset=utf-8",
|
|
7148
|
-
"Content-Length":
|
|
7149
|
-
}
|
|
7150
|
-
|
|
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);
|
|
7151
7687
|
} catch (error) {
|
|
7152
7688
|
holder.request?.destroy();
|
|
7153
7689
|
if (error instanceof HttpError) throw error;
|
|
@@ -7345,7 +7881,7 @@ var MobileAccessGateway = class {
|
|
|
7345
7881
|
const headers = {
|
|
7346
7882
|
"Content-Type": "text/javascript; charset=utf-8",
|
|
7347
7883
|
"Content-Length": body.byteLength,
|
|
7348
|
-
"Cache-Control": "private,
|
|
7884
|
+
"Cache-Control": "private, max-age=30, must-revalidate",
|
|
7349
7885
|
ETag: etag
|
|
7350
7886
|
};
|
|
7351
7887
|
if (compressed) headers["Content-Encoding"] = "gzip";
|
|
@@ -7354,7 +7890,7 @@ var MobileAccessGateway = class {
|
|
|
7354
7890
|
setSecurityHeaders(response, this.tlsEnabled);
|
|
7355
7891
|
response.writeHead(304, {
|
|
7356
7892
|
ETag: etag,
|
|
7357
|
-
"Cache-Control": "private,
|
|
7893
|
+
"Cache-Control": "private, max-age=30, must-revalidate",
|
|
7358
7894
|
Vary: String(headers.vary)
|
|
7359
7895
|
});
|
|
7360
7896
|
response.end();
|
|
@@ -7533,6 +8069,16 @@ var MobileAccessGateway = class {
|
|
|
7533
8069
|
operation.abort();
|
|
7534
8070
|
};
|
|
7535
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
|
+
});
|
|
7536
8082
|
if (replayable) request.resume();
|
|
7537
8083
|
try {
|
|
7538
8084
|
const attempts = replayable ? MOBILE_BOOT_UPSTREAM_ATTEMPTS : 1;
|
|
@@ -8041,6 +8587,8 @@ var MobileAccessGateway = class {
|
|
|
8041
8587
|
}
|
|
8042
8588
|
async performClose() {
|
|
8043
8589
|
this.closing = true;
|
|
8590
|
+
const failures = [];
|
|
8591
|
+
const directoryDrain = Promise.all(this.workspaceDirectoryTasks);
|
|
8044
8592
|
for (const controller of this.clientModuleOperations) controller.abort();
|
|
8045
8593
|
if (this.extensionChangeTimer !== void 0) clearInterval(this.extensionChangeTimer);
|
|
8046
8594
|
this.extensionChangeTimer = void 0;
|
|
@@ -8050,7 +8598,9 @@ var MobileAccessGateway = class {
|
|
|
8050
8598
|
this.removeSessionListener();
|
|
8051
8599
|
this.pendingDeviceRevocations.clear();
|
|
8052
8600
|
for (const view of this.mobileDocumentViews.values()) this.removeMobileDocumentView(view);
|
|
8053
|
-
const accessClose = this.access.close()
|
|
8601
|
+
const accessClose = this.access.close().catch((error) => {
|
|
8602
|
+
failures.push(error);
|
|
8603
|
+
});
|
|
8054
8604
|
for (const stored of this.mobileBootBatches.values()) stored.assembly?.controller.abort();
|
|
8055
8605
|
for (const request of this.activeRequests.values()) {
|
|
8056
8606
|
clearTimeout(request.timer);
|
|
@@ -8063,19 +8613,29 @@ var MobileAccessGateway = class {
|
|
|
8063
8613
|
for (const socket of this.connectedSockets) socket.destroy();
|
|
8064
8614
|
if (this.discoveryTimer !== void 0) clearInterval(this.discoveryTimer);
|
|
8065
8615
|
this.discoveryTimer = void 0;
|
|
8066
|
-
await
|
|
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
|
+
});
|
|
8067
8620
|
const discoverySocket = this.discoverySocket;
|
|
8068
8621
|
this.discoverySocket = void 0;
|
|
8069
8622
|
if (discoverySocket !== void 0) await new Promise((resolve) => {
|
|
8070
8623
|
discoverySocket.close(() => resolve());
|
|
8624
|
+
}).catch((error) => {
|
|
8625
|
+
failures.push(error);
|
|
8071
8626
|
});
|
|
8072
8627
|
const server = this.server;
|
|
8073
8628
|
this.server = void 0;
|
|
8074
|
-
if (server !== void 0 && server.listening) {
|
|
8629
|
+
if (server !== void 0 && server.listening) try {
|
|
8075
8630
|
server.closeAllConnections();
|
|
8076
|
-
await new Promise((resolve) => {
|
|
8077
|
-
server.close(() =>
|
|
8631
|
+
await new Promise((resolve, reject) => {
|
|
8632
|
+
server.close((error) => {
|
|
8633
|
+
if (error) reject(error);
|
|
8634
|
+
else resolve();
|
|
8635
|
+
});
|
|
8078
8636
|
});
|
|
8637
|
+
} catch (error) {
|
|
8638
|
+
failures.push(error);
|
|
8079
8639
|
}
|
|
8080
8640
|
await accessClose;
|
|
8081
8641
|
await Promise.allSettled(this.clientModuleTasks);
|
|
@@ -8086,6 +8646,7 @@ var MobileAccessGateway = class {
|
|
|
8086
8646
|
this.connectedSockets.clear();
|
|
8087
8647
|
this.policy = void 0;
|
|
8088
8648
|
this.listenerPort = void 0;
|
|
8649
|
+
if (failures.length > 0) throw new AggregateError(failures, "mobile gateway cleanup failed");
|
|
8089
8650
|
}
|
|
8090
8651
|
/** Safe metadata helper for direct loopback integrations. */
|
|
8091
8652
|
devices() {
|
|
@@ -17670,16 +18231,20 @@ async function loadOrCreateInstallationId(file) {
|
|
|
17670
18231
|
//#endregion
|
|
17671
18232
|
//#region src/gateway-identity.ts
|
|
17672
18233
|
/**
|
|
17673
|
-
*
|
|
18234
|
+
* Attach the authenticated installation identity while retaining existing entry identity rules.
|
|
17674
18235
|
* @param config - Validated gateway configuration containing the legacy identity.
|
|
17675
18236
|
* @param installationId - Confirmed random identity read from this installation's state.
|
|
17676
18237
|
* @param explicitIdentity - Whether the operator supplied the gateway identity.
|
|
17677
18238
|
* @returns Configuration retaining certificate/operator identities or the new and legacy public identities.
|
|
17678
18239
|
*/
|
|
17679
18240
|
function withInstallationIdentity(config, installationId, explicitIdentity) {
|
|
17680
|
-
if (config.pairingCaFile !== void 0 || explicitIdentity) return
|
|
18241
|
+
if (config.pairingCaFile !== void 0 || explicitIdentity) return Object.freeze({
|
|
18242
|
+
...config,
|
|
18243
|
+
installationId
|
|
18244
|
+
});
|
|
17681
18245
|
return Object.freeze({
|
|
17682
18246
|
...config,
|
|
18247
|
+
installationId,
|
|
17683
18248
|
instanceId: installationId,
|
|
17684
18249
|
legacyInstanceId: config.instanceId
|
|
17685
18250
|
});
|
|
@@ -18065,10 +18630,10 @@ async function apply(ctx, config) {
|
|
|
18065
18630
|
const dshVersion = installedDshVersion();
|
|
18066
18631
|
const loaded = await loadSetup(config);
|
|
18067
18632
|
const mobileAccess = createMobileAccessService(ctx);
|
|
18068
|
-
const
|
|
18069
|
-
const upstreamLoginUrl = upstreamAuthenticatedUrl(ctx,
|
|
18070
|
-
const instanceId = await stableInstanceId(loaded,
|
|
18071
|
-
const stateDirectory = dirname(
|
|
18633
|
+
const baseTemplate = loopbackTemplate(loaded, ctx.webServer.port);
|
|
18634
|
+
const upstreamLoginUrl = upstreamAuthenticatedUrl(ctx, baseTemplate.upstreamOrigin);
|
|
18635
|
+
const instanceId = await stableInstanceId(loaded, baseTemplate);
|
|
18636
|
+
const stateDirectory = dirname(baseTemplate.stateFile);
|
|
18072
18637
|
const logFile = await installMobileFileLogger(ctx, stateDirectory);
|
|
18073
18638
|
const logger = ctx.logger("dsh-mobile");
|
|
18074
18639
|
logger.info("logging initialized file=%s", logFile);
|
|
@@ -18091,6 +18656,10 @@ async function apply(ctx, config) {
|
|
|
18091
18656
|
const frpComponent = new FrpComponentManager({ stateDirectory });
|
|
18092
18657
|
await frpComponent.initialize();
|
|
18093
18658
|
const frpIdentity = await loadOrCreateInstallationId(join(stateDirectory, "installation-id"));
|
|
18659
|
+
const template = Object.freeze({
|
|
18660
|
+
...baseTemplate,
|
|
18661
|
+
installationId: frpIdentity
|
|
18662
|
+
});
|
|
18094
18663
|
const remoteInstanceId = loaded.kind === "managed" ? frpIdentity : loaded.config.instanceId ?? frpIdentity;
|
|
18095
18664
|
const frpConfig = new FrpConfigStore(join(remoteDirectory, "frp", "config"), frpProxyName(frpIdentity), stateDirectory);
|
|
18096
18665
|
await frpConfig.initialize();
|
|
@@ -18155,6 +18724,8 @@ async function apply(ctx, config) {
|
|
|
18155
18724
|
logger.warn("http failure %s", JSON.stringify(entry));
|
|
18156
18725
|
}, (entry) => {
|
|
18157
18726
|
logger.info("upstream auth %s", JSON.stringify(entry));
|
|
18727
|
+
}, (entry) => {
|
|
18728
|
+
logger.warn("slow api %s", JSON.stringify(entry));
|
|
18158
18729
|
});
|
|
18159
18730
|
await candidate.start();
|
|
18160
18731
|
lanGateway = candidate;
|
|
@@ -18217,6 +18788,8 @@ async function apply(ctx, config) {
|
|
|
18217
18788
|
logger.warn("http failure %s", JSON.stringify(entry));
|
|
18218
18789
|
}, (entry) => {
|
|
18219
18790
|
logger.info("upstream auth %s", JSON.stringify(entry));
|
|
18791
|
+
}, (entry) => {
|
|
18792
|
+
logger.warn("slow api %s", JSON.stringify(entry));
|
|
18220
18793
|
});
|
|
18221
18794
|
await candidate.start();
|
|
18222
18795
|
return candidate;
|
|
@@ -18239,6 +18812,8 @@ async function apply(ctx, config) {
|
|
|
18239
18812
|
logger.warn("http failure %s", JSON.stringify(entry));
|
|
18240
18813
|
}, (entry) => {
|
|
18241
18814
|
logger.info("upstream auth %s", JSON.stringify(entry));
|
|
18815
|
+
}, (entry) => {
|
|
18816
|
+
logger.warn("slow api %s", JSON.stringify(entry));
|
|
18242
18817
|
});
|
|
18243
18818
|
try {
|
|
18244
18819
|
await candidate.start();
|
|
@@ -18256,6 +18831,8 @@ async function apply(ctx, config) {
|
|
|
18256
18831
|
logger.warn("http failure %s", JSON.stringify(entry));
|
|
18257
18832
|
}, (entry) => {
|
|
18258
18833
|
logger.info("upstream auth %s", JSON.stringify(entry));
|
|
18834
|
+
}, (entry) => {
|
|
18835
|
+
logger.warn("slow api %s", JSON.stringify(entry));
|
|
18259
18836
|
});
|
|
18260
18837
|
try {
|
|
18261
18838
|
await candidate.start();
|
|
@@ -18480,8 +19057,8 @@ async function apply(ctx, config) {
|
|
|
18480
19057
|
}
|
|
18481
19058
|
if (request.method !== "POST") throw new HttpError(405, "method_not_allowed");
|
|
18482
19059
|
const body = await readJsonObject(request, template.maxBodyBytes);
|
|
18483
|
-
if (Object.keys(body).length !== 1 || Object.keys(body).some((key) => key !== "reset" && key !== "excludedClientModules")) throw new HttpError(400, "bad_request");
|
|
18484
|
-
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);
|
|
18485
19062
|
} catch (error) {
|
|
18486
19063
|
if (!(error instanceof ClientModuleConflictError)) throw error;
|
|
18487
19064
|
sendJson(response, 409, {
|