- ViewerRuntime.js: convert 6 heavy static imports (abCompare, sideBySide, model3d, scopes, genInfo, frameExport) to dynamic lazy imports with _prewarmViewerModules(); all 8 call sites updated with optional chaining - logging.js: add mjrDbg() export (gated by APP_CONFIG.DEBUG_VERBOSE_ERRORS); replace labeled console.debug() calls in bootstrap.js, useGridLoader.js, entry.js - ttlCache.vitest.mjs: add 4 LRU edge-case tests (10k flood, re-set order, post-expiry pruning) - grid_perf_budget.vitest.mjs: new CI perf-budget suite — 5 tests at 10k+ assets (budget 400ms, actual ~40ms) - backfill_jobs.py: new route module extracted from db_maintenance handler - test_backfill_jobs.py: 61 direct unit tests covering all public functions - test_db_corruption_recovery.py: new recovery scenario tests - auth/fetch test suites: auth_refresh_race, client_auth_token, fetch_utils - js_dist rebuilt Tests: 393 JS passed, 1428 Python passed, 2 skipped
435 lines
15 KiB
JavaScript
435 lines
15 KiB
JavaScript
const WRITE_METHODS = new Set(["POST", "PUT", "DELETE", "PATCH"]);
|
|
const DEFAULT_FETCH_TIMEOUT_MS = 20_000;
|
|
const MAX_FETCH_TIMEOUT_MS = 300_000;
|
|
const MAX_RETRIES = 3;
|
|
const RETRY_BASE_DELAY_MS = 400;
|
|
const BOOTSTRAP_TOKEN_PATH = "/mjr/am/settings/security/bootstrap-token";
|
|
const API_PREFIX = "/mjr/am/";
|
|
const _pendingRequests = new Map();
|
|
|
|
export function delay(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function _methodIsWrite(method) {
|
|
return WRITE_METHODS.has(String(method || "").trim().toUpperCase());
|
|
}
|
|
|
|
function _normalizeUrlPath(url) {
|
|
try {
|
|
const raw = String(url || "").trim();
|
|
if (!raw) return "";
|
|
if (raw.startsWith("http://") || raw.startsWith("https://")) {
|
|
const base =
|
|
typeof globalThis !== "undefined" && globalThis?.location?.origin
|
|
? String(globalThis.location.origin)
|
|
: "http://localhost";
|
|
return new URL(raw, base).pathname || "";
|
|
}
|
|
return raw.split("?")[0] || "";
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function _isMajoorApiUrl(url) {
|
|
return _normalizeUrlPath(url).startsWith(API_PREFIX);
|
|
}
|
|
|
|
function _isBootstrapTokenUrl(url) {
|
|
return _normalizeUrlPath(url) === BOOTSTRAP_TOKEN_PATH;
|
|
}
|
|
|
|
function _isRetryableError(error) {
|
|
try {
|
|
if (!error) return false;
|
|
const name = String(error.name || "");
|
|
if (name === "AbortError") return false;
|
|
const msg = String(error.message || "").toLowerCase();
|
|
if (name === "TypeError") {
|
|
return (
|
|
msg.includes("failed to fetch") ||
|
|
msg.includes("networkerror") ||
|
|
msg.includes("load failed") ||
|
|
msg.includes("fetch") ||
|
|
msg.includes("network")
|
|
);
|
|
}
|
|
return msg.includes("fetch") || msg.includes("network") || msg.includes("failed");
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function _resolveFetchTimeoutMs(options = {}) {
|
|
try {
|
|
const raw = Number(options?.timeoutMs);
|
|
if (!Number.isFinite(raw)) return DEFAULT_FETCH_TIMEOUT_MS;
|
|
return Math.max(1_000, Math.min(MAX_FETCH_TIMEOUT_MS, Math.floor(raw)));
|
|
} catch {
|
|
return DEFAULT_FETCH_TIMEOUT_MS;
|
|
}
|
|
}
|
|
|
|
function _buildTimedSignal(options = {}) {
|
|
const upstreamSignal = options?.signal || null;
|
|
if (typeof AbortController === "undefined") {
|
|
return {
|
|
signal: upstreamSignal || undefined,
|
|
timeoutMs: _resolveFetchTimeoutMs(options),
|
|
cleanup: () => {},
|
|
};
|
|
}
|
|
const timeoutMs = _resolveFetchTimeoutMs(options);
|
|
const ctrl = new AbortController();
|
|
let timer = null;
|
|
const onAbort = () => {
|
|
try {
|
|
if (timer) {
|
|
clearTimeout(timer);
|
|
timer = null;
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
ctrl.abort();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
};
|
|
try {
|
|
timer = setTimeout(() => {
|
|
try {
|
|
ctrl.abort();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}, timeoutMs);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
if (upstreamSignal) {
|
|
if (upstreamSignal.aborted) {
|
|
onAbort();
|
|
} else {
|
|
upstreamSignal.addEventListener("abort", onAbort, { once: true });
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
return {
|
|
signal: ctrl.signal,
|
|
timeoutMs,
|
|
cleanup: () => {
|
|
try {
|
|
if (timer) clearTimeout(timer);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
if (upstreamSignal) upstreamSignal.removeEventListener("abort", onAbort);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
function _buildPendingRequestKey(method, url, options = {}) {
|
|
const normalizedMethod = String(method || "GET").trim().toUpperCase();
|
|
const normalizedUrl = String(url || "").trim();
|
|
if (!normalizedMethod || !normalizedUrl) return "";
|
|
const timeoutMs = _resolveFetchTimeoutMs(options);
|
|
return `${normalizedMethod}:${normalizedUrl}:timeout=${timeoutMs}`;
|
|
}
|
|
|
|
function _deduplicatedFetch(key, fetcher) {
|
|
const normalizedKey = String(key || "").trim();
|
|
if (!normalizedKey) return fetcher();
|
|
if (_pendingRequests.has(normalizedKey)) {
|
|
return _pendingRequests.get(normalizedKey);
|
|
}
|
|
const pending = Promise.resolve()
|
|
.then(() => fetcher())
|
|
.finally(() => {
|
|
try {
|
|
_pendingRequests.delete(normalizedKey);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
});
|
|
_pendingRequests.set(normalizedKey, pending);
|
|
return pending;
|
|
}
|
|
|
|
/**
|
|
* Inject CSRF and observability headers in-place.
|
|
*
|
|
* Mutates `headers` directly (avoids an extra async tick that would break
|
|
* deduplication timing if this were an async helper).
|
|
*
|
|
* @param {Headers|object} headers - Mutable headers object.
|
|
* @param {string} method - Uppercased HTTP method.
|
|
* @param {boolean} obsEnabled - Whether observability is on.
|
|
*/
|
|
function _injectBaseHeaders(headers, method, obsEnabled) {
|
|
// 1. CSRF protection — required on all write methods.
|
|
if (_methodIsWrite(method)) {
|
|
try {
|
|
if (headers instanceof Headers) {
|
|
if (!headers.has("X-Requested-With")) headers.set("X-Requested-With", "XMLHttpRequest");
|
|
} else if (!headers["X-Requested-With"]) {
|
|
headers["X-Requested-With"] = "XMLHttpRequest";
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
// 2. Observability opt-in.
|
|
try {
|
|
if (headers instanceof Headers) {
|
|
if (!headers.has("X-MJR-OBS")) headers.set("X-MJR-OBS", obsEnabled ? "on" : "off");
|
|
} else if (!("X-MJR-OBS" in headers)) {
|
|
headers["X-MJR-OBS"] = obsEnabled ? "on" : "off";
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Inject auth token headers in-place.
|
|
*
|
|
* @param {Headers|object} headers - Mutable headers object.
|
|
* @param {string} authToken - Token value (empty string → no-op).
|
|
*/
|
|
function _injectAuthToken(headers, authToken) {
|
|
if (!authToken) return;
|
|
try {
|
|
if (headers instanceof Headers) {
|
|
if (!headers.has("X-MJR-Token")) headers.set("X-MJR-Token", authToken);
|
|
if (!headers.has("Authorization")) headers.set("Authorization", `Bearer ${authToken}`);
|
|
} else {
|
|
if (!("X-MJR-Token" in headers)) headers["X-MJR-Token"] = authToken;
|
|
if (!("Authorization" in headers)) headers["Authorization"] = `Bearer ${authToken}`;
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse a fetch Response and handle auth refresh if needed.
|
|
*
|
|
* Covers:
|
|
* - Non-JSON content-type → INVALID_RESPONSE (with optional auth-retry on 401).
|
|
* - JSON parse failure → INVALID_RESPONSE.
|
|
* - AUTH_REQUIRED / 401 JSON response → one-shot auth refresh + retry.
|
|
* - Write-auth failure normalization.
|
|
*
|
|
* @param {Response} response
|
|
* @param {string} url
|
|
* @param {string} method
|
|
* @param {boolean} authRetryDone - True when this is already the retry after an auth refresh.
|
|
* @param {object} deps - { ensureWriteAuthToken, normalizeWriteAuthFailure, fetchAPI, options, retryCount }
|
|
* @returns {Promise<object>} ApiResult
|
|
*/
|
|
async function _handleResponse(response, url, method, authRetryDone, { ensureWriteAuthToken, normalizeWriteAuthFailure, fetchAPI, options, retryCount }) {
|
|
const contentType = response.headers.get("content-type") || "";
|
|
|
|
if (!contentType.includes("application/json")) {
|
|
// On a non-JSON 401, try one auth refresh before giving up.
|
|
if (
|
|
!authRetryDone &&
|
|
_methodIsWrite(method) &&
|
|
_isMajoorApiUrl(url) &&
|
|
!_isBootstrapTokenUrl(url) &&
|
|
Number(response.status || 0) === 401
|
|
) {
|
|
const refreshed = await ensureWriteAuthToken({ force: true, allowCookieRefresh: true });
|
|
if (refreshed) {
|
|
return await fetchAPI(url, { ...options, _authRetryDone: true }, retryCount);
|
|
}
|
|
}
|
|
return {
|
|
ok: false,
|
|
error: `Server returned non-JSON response (${response.status})`,
|
|
code: "INVALID_RESPONSE",
|
|
status: response.status,
|
|
content_type: contentType,
|
|
data: null,
|
|
};
|
|
}
|
|
|
|
let result = await response.json().catch((e) => {
|
|
console.debug?.("[MJR API] JSON parse error:", e);
|
|
return null;
|
|
});
|
|
|
|
if (typeof result !== "object" || result === null) {
|
|
return {
|
|
ok: false,
|
|
error: "Invalid response structure",
|
|
code: "INVALID_RESPONSE",
|
|
status: response.status,
|
|
data: null,
|
|
};
|
|
}
|
|
|
|
if (!("status" in result)) {
|
|
try { result.status = response.status; } catch (e) { console.debug?.(e); }
|
|
}
|
|
|
|
// On an AUTH_REQUIRED / 401 JSON response, try one token refresh + retry.
|
|
const shouldTryAuthRefresh =
|
|
!authRetryDone &&
|
|
_methodIsWrite(method) &&
|
|
!_isBootstrapTokenUrl(url) &&
|
|
!result?.ok &&
|
|
(String(result?.code || "").toUpperCase() === "AUTH_REQUIRED" ||
|
|
Number(result?.status || 0) === 401);
|
|
|
|
if (shouldTryAuthRefresh) {
|
|
const refreshed = await ensureWriteAuthToken({ force: true, allowCookieRefresh: true });
|
|
if (refreshed) {
|
|
return await fetchAPI(url, { ...options, _authRetryDone: true }, retryCount);
|
|
}
|
|
}
|
|
|
|
if (_methodIsWrite(method) && _isMajoorApiUrl(url) && !_isBootstrapTokenUrl(url)) {
|
|
result = normalizeWriteAuthFailure(result);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Create the shared API fetch helpers used by the Majoor client.
|
|
*/
|
|
export function createApiFetchClient({
|
|
readObsEnabled = () => false,
|
|
readAuthToken = () => "",
|
|
ensureWriteAuthToken = async () => "",
|
|
normalizeWriteAuthFailure = (result) => result,
|
|
trackApiCall = null,
|
|
} = {}) {
|
|
async function fetchAPI(url, options = {}, retryCount = 0) {
|
|
const apiStartTime = typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
const timed = _buildTimedSignal(options);
|
|
let result = null;
|
|
try {
|
|
const method = (options.method || "GET").toUpperCase();
|
|
const authRetryDone = !!options?._authRetryDone;
|
|
|
|
// Build headers synchronously so no extra microtask tick is added
|
|
// before await fetch() — this preserves deduplication timing.
|
|
const headers =
|
|
typeof Headers !== "undefined"
|
|
? new Headers(options.headers || {})
|
|
: { ...options.headers };
|
|
_injectBaseHeaders(headers, method, !!readObsEnabled());
|
|
|
|
// Bootstrap auth token for write requests only when missing.
|
|
let authToken = readAuthToken();
|
|
if (!authToken && _methodIsWrite(method) && _isMajoorApiUrl(url) && !_isBootstrapTokenUrl(url)) {
|
|
try { await ensureWriteAuthToken(); } catch (e) { console.debug?.(e); }
|
|
authToken = readAuthToken();
|
|
}
|
|
_injectAuthToken(headers, authToken);
|
|
|
|
const fetchOptions = { ...options, headers, signal: timed.signal };
|
|
try {
|
|
delete fetchOptions._authRetryDone;
|
|
delete fetchOptions.timeoutMs;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
const response = await fetch(url, fetchOptions);
|
|
result = await _handleResponse(response, url, method, authRetryDone, {
|
|
ensureWriteAuthToken,
|
|
normalizeWriteAuthFailure,
|
|
fetchAPI,
|
|
options,
|
|
retryCount,
|
|
});
|
|
return result;
|
|
} catch (error) {
|
|
try {
|
|
if (String(error?.name || "") === "AbortError") {
|
|
if (options?.signal && options.signal.aborted) {
|
|
return { ok: false, error: "Aborted", code: "ABORTED", data: null };
|
|
}
|
|
return {
|
|
ok: false,
|
|
error: `Request timed out after ${timed.timeoutMs}ms`,
|
|
code: "TIMEOUT",
|
|
data: null,
|
|
timeout_ms: timed.timeoutMs,
|
|
};
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
if (retryCount < MAX_RETRIES && _isRetryableError(error)) {
|
|
try {
|
|
await delay(RETRY_BASE_DELAY_MS * (retryCount + 1));
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
// fetchAPI catches all errors internally — no try/catch needed here.
|
|
// A wrapping try/catch would swallow the retry result and fall through
|
|
// to the NETWORK_ERROR return below with the *original* error instead.
|
|
return await fetchAPI(url, options, retryCount + 1);
|
|
}
|
|
return {
|
|
ok: false,
|
|
error: error?.message || String(error || "Network error"),
|
|
code: "NETWORK_ERROR",
|
|
data: null,
|
|
retries: retryCount,
|
|
};
|
|
} finally {
|
|
try {
|
|
const apiEndTime = typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
const duration = apiEndTime - apiStartTime;
|
|
if (typeof trackApiCall === "function") {
|
|
trackApiCall(duration, !result?.ok);
|
|
} else if (typeof window !== "undefined" && window.MajoorMetrics) {
|
|
window.MajoorMetrics.trackApiCall(duration, !result?.ok);
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
timed.cleanup?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function get(url, options = {}) {
|
|
const dedupeKey =
|
|
options?.dedupe === false
|
|
? ""
|
|
: String(options?.dedupeKey || "").trim() ||
|
|
_buildPendingRequestKey("GET", url, options);
|
|
return _deduplicatedFetch(dedupeKey, () => fetchAPI(url, { ...options, method: "GET" }));
|
|
}
|
|
|
|
async function post(url, body, options = {}) {
|
|
return fetchAPI(url, {
|
|
...options,
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", ...options.headers },
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
return { fetchAPI, get, post };
|
|
}
|