Files
Ewald c1cdfacff5 fix(ui,ci): address audit findings on sidebar prewarm, topbar button, API dedupe/validation, and frontend-types CI
- Sidebar prewarm no longer steals the Vue host from an already-open panel:
  mountKeepAlive only attaches on first creation unless explicitly allowed,
  and teardown resets the prewarm state and removes its detached container.
- Topbar MFV button now also observes the actionbar container's parent so a
  full replacement of .actionbar-container (not just mutations inside it) is
  detected and the button is remounted.
- get() skips request deduplication when a caller passes its own AbortSignal,
  since the shared fetch only ever wired up the first caller's signal.
- The API client now rejects response envelopes missing a boolean `ok` field
  (including bare arrays) and downgrades ok:true responses paired with an
  HTTP error status to INVALID_RESPONSE.
- frontend-compat-nightly.yml: upstream ComfyUI_frontend's build:types now
  emits per-file declarations mirroring src/ instead of one bundled file, so
  the previous entrypoint paths no longer exist. The activation step now also
  checks dist/src/types/index.d.ts and copies the whole dist/ tree so the
  unbundled declaration's relative imports keep resolving.
2026-09-11 11:42:32 +02:00

549 lines
19 KiB
TypeScript

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 MAX_RETRY_AFTER_MS = 10_000;
const BOOTSTRAP_TOKEN_PATH = "/mjr/am/settings/security/bootstrap-token";
const API_PREFIX = "/mjr/am/";
const _pendingRequests = new Map();
export function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function _methodIsWrite(method: any) {
return WRITE_METHODS.has(
String(method || "")
.trim()
.toUpperCase(),
);
}
function _normalizeUrlPath(url: any) {
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: any) {
return _normalizeUrlPath(url).startsWith(API_PREFIX);
}
function _isBootstrapTokenUrl(url: any) {
return _normalizeUrlPath(url) === BOOTSTRAP_TOKEN_PATH;
}
function _isRetryableError(error: any) {
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 _responseMetadata(response: any) {
try {
const requestId = String(response?.headers?.get?.("x-request-id") || "").trim();
const retryAfterRaw = String(response?.headers?.get?.("retry-after") || "").trim();
let retryAfterSeconds: number | null = null;
if (retryAfterRaw) {
const numeric = Number(retryAfterRaw);
if (Number.isFinite(numeric) && numeric >= 0) {
retryAfterSeconds = numeric;
} else {
const retryDate = Date.parse(retryAfterRaw);
if (Number.isFinite(retryDate)) {
retryAfterSeconds = Math.max(0, (retryDate - Date.now()) / 1000);
}
}
}
return { requestId, retryAfterSeconds };
} catch {
return { requestId: "", retryAfterSeconds: null };
}
}
function _attachResponseMetadata(result: any, response: any) {
if (!result || typeof result !== "object") return result;
const { requestId, retryAfterSeconds } = _responseMetadata(response);
if (requestId && !("request_id" in result)) result.request_id = requestId;
if (retryAfterSeconds !== null && !("retry_after" in result)) {
result.retry_after = retryAfterSeconds;
}
return result;
}
function _resolveFetchTimeoutMs(options: Record<string, any> = {}) {
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: Record<string, any> = {}) {
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: ReturnType<typeof setTimeout> | null = 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: any, url: any, options: Record<string, any> = {}) {
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: any, fetcher: any) {
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: any, method: any, obsEnabled: any) {
// 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: any, authToken: any) {
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: any,
url: any,
method: any,
authRetryDone: any,
{ ensureWriteAuthToken, normalizeWriteAuthFailure, fetchAPI, options, retryCount }: Record<string, any>,
) {
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 _attachResponseMetadata({
ok: false,
error: `Server returned non-JSON response (${response.status})`,
code: "INVALID_RESPONSE",
status: response.status,
content_type: contentType,
data: null,
}, response);
}
let result = await response.json().catch((e: any) => {
console.debug?.("[MJR API] JSON parse error:", e);
return null;
});
// The `ApiResult` envelope contract requires a boolean `ok` field - an
// array (`[]`) or any object missing it is not a valid envelope, even
// though it parses as JSON.
if (
typeof result !== "object" ||
result === null ||
Array.isArray(result) ||
typeof result.ok !== "boolean"
) {
return _attachResponseMetadata({
ok: false,
error: "Invalid response structure",
code: "INVALID_RESPONSE",
status: response.status,
data: null,
}, response);
}
// Guard against an envelope that disagrees with the transport: a server
// error status paired with `ok:true` is an inconsistency we must not
// forward as success.
if (result.ok === true && Number(response.status || 0) >= 400) {
result = {
...result,
ok: false,
error:
result.error ||
`Server reported success with an HTTP error status (${response.status})`,
code: "INVALID_RESPONSE",
};
}
if (!("status" in result)) {
try {
result.status = response.status;
} catch (e) {
console.debug?.(e);
}
}
_attachResponseMetadata(result, response);
const status = Number(result?.status || response.status || 0);
const retryableStatus = status === 429 || status === 503 || status === 504;
if (
!result?.ok &&
retryableStatus &&
method === "GET" &&
retryCount < MAX_RETRIES &&
!options?.signal?.aborted
) {
const retryAfterSeconds = Number(result?.retry_after);
const retryDelayMs = Number.isFinite(retryAfterSeconds)
? Math.min(MAX_RETRY_AFTER_MS, Math.max(0, retryAfterSeconds * 1000))
: RETRY_BASE_DELAY_MS * (retryCount + 1);
await delay(retryDelayMs);
return await fetchAPI(url, options, retryCount + 1);
}
// 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: any) => result,
trackApiCall = null,
}: {
readObsEnabled?: () => boolean;
readAuthToken?: () => string;
ensureWriteAuthToken?: (...args: any[]) => Promise<string>;
normalizeWriteAuthFailure?: (result: any) => any;
trackApiCall?: ((duration: number, failed: boolean) => void) | null;
} = {}) {
async function fetchAPI(url: string, options: Record<string, any> = {}, 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: Record<string, any> = { ...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: any) {
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);
}
const requestMethod = String(options?.method || "GET").toUpperCase();
const canReplayRequest = requestMethod === "GET" || requestMethod === "HEAD";
if (canReplayRequest && 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 as any)?.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 as any).MajoorMetrics) {
(window as any).MajoorMetrics.trackApiCall(duration, !result?.ok);
}
} catch (e) {
console.debug?.(e);
}
try {
timed.cleanup?.();
} catch (e) {
console.debug?.(e);
}
}
}
async function get(url: string, options: Record<string, any> = {}) {
// A caller-supplied AbortSignal is independent per-caller: the shared
// fetch behind a dedupe key only ever wires up the *first* caller's
// signal, so a second caller aborting its own signal would have no
// effect, and the first caller aborting would wrongly abort the
// second caller's request too. Skip dedup whenever a signal is
// present so each caller controls its own request lifecycle.
const dedupeKey =
options?.dedupe === false || options?.signal
? ""
: String(options?.dedupeKey || "").trim() ||
_buildPendingRequestKey("GET", url, options);
return _deduplicatedFetch(dedupeKey, () => fetchAPI(url, { ...options, method: "GET" }));
}
async function post(url: string, body: unknown, options: Record<string, any> = {}) {
return fetchAPI(url, {
...options,
method: "POST",
headers: { "Content-Type": "application/json", ...options.headers },
body: JSON.stringify(body),
});
}
return { fetchAPI, get, post };
}