- 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.
549 lines
19 KiB
TypeScript
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 };
|
|
}
|