291 lines
10 KiB
TypeScript
291 lines
10 KiB
TypeScript
/**
|
|
* Early-fetch coordinator for the assets list.
|
|
*
|
|
* Lightweight module imported as early as possible (top-level side effect from
|
|
* `entry.js`) so the first /list request leaves the browser BEFORE ComfyUI's
|
|
* extension manager finishes initialising. This shaves seconds off the first
|
|
* paint of the Assets Manager grid on cold ComfyUI starts: previously the
|
|
* fetch only fired from inside `setup()` step 8b, well after `waitForComfyApp`
|
|
* polling, lazy module imports and global runtime mount.
|
|
*
|
|
* The module intentionally has only two dependencies (`api/client.js` and
|
|
* `api/endpoints.js`) so importing it does not pull Vue, Pinia or any UI
|
|
* surface into the initial parse.
|
|
*/
|
|
|
|
import { get } from "../../api/client.js";
|
|
import { buildListURL, ENDPOINTS } from "../../api/endpoints.js";
|
|
import { APP_CONFIG } from "../../app/config.js";
|
|
|
|
const PANEL_STATE_KEY = "mjr_panel_state";
|
|
const EARLY_FETCH_ENABLED = false;
|
|
const ALLOWED_SCOPES = new Set(["output", "input", "all", "custom", "collection"]);
|
|
|
|
// 15 seconds — wide enough to cover slow ComfyUI startups and delayed sidebar
|
|
// opens while still invalidating stale data before it becomes misleading.
|
|
const EARLY_FETCH_TTL_MS = 15000;
|
|
|
|
let _earlyFetchPromise: any = null;
|
|
let _earlyFetchKey: any = null;
|
|
let _earlyFetchTimestamp = 0;
|
|
let _earlyFetchAC: any = null;
|
|
let _pageHideBound = false;
|
|
|
|
function _readPersistedPanelState() {
|
|
try {
|
|
const raw = globalThis?.localStorage?.getItem?.(PANEL_STATE_KEY);
|
|
if (!raw) return null;
|
|
const parsed = JSON.parse(raw);
|
|
return parsed && typeof parsed === "object" ? parsed : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function _normalizeScope(value: any) {
|
|
const s = String(value || "output")
|
|
.trim()
|
|
.toLowerCase();
|
|
if (s === "outputs") return "output";
|
|
if (s === "inputs") return "input";
|
|
return ALLOWED_SCOPES.has(s) ? s : "output";
|
|
}
|
|
|
|
/**
|
|
* Reads the persisted Pinia panel state and exposes the fields needed to
|
|
* rebuild a /list URL that matches whatever the user last left the panel in.
|
|
* Returning the full filter set (instead of just scope+sort) is what allows
|
|
* the early fetch to be reused even when the user has saved filters such as
|
|
* `workflowOnly` or `minRating>0`.
|
|
*/
|
|
function _readPersistedListContext() {
|
|
const state = _readPersistedPanelState();
|
|
const scope = _normalizeScope(state?.scope);
|
|
const sort =
|
|
String(state?.sort || "mtime_desc")
|
|
.trim()
|
|
.toLowerCase() || "mtime_desc";
|
|
const customRootId = String(state?.customRootId || "").trim();
|
|
const subfolder = String(state?.currentFolderRelativePath || "").trim();
|
|
const collectionId = String(state?.collectionId || "").trim();
|
|
const kind = String(state?.kindFilter || "").trim();
|
|
const workflowOnly = !!state?.workflowOnly;
|
|
const minRating = Number(state?.minRating || 0) || 0;
|
|
const minSizeMB = Number(state?.minSizeMB || 0) || 0;
|
|
const maxSizeMB = Number(state?.maxSizeMB || 0) || 0;
|
|
const resolutionCompare = state?.resolutionCompare === "lte" ? "lte" : "gte";
|
|
const minWidth = Number(state?.minWidth || 0) || 0;
|
|
const minHeight = Number(state?.minHeight || 0) || 0;
|
|
const maxWidth = Number(state?.maxWidth || 0) || 0;
|
|
const maxHeight = Number(state?.maxHeight || 0) || 0;
|
|
const workflowType = String(state?.workflowType || "").trim();
|
|
const dateRange = String(state?.dateRangeFilter || "")
|
|
.trim()
|
|
.toLowerCase();
|
|
const dateExact = String(state?.dateExactFilter || "").trim();
|
|
const searchQuery = String(state?.searchQuery || "").trim();
|
|
return {
|
|
scope,
|
|
sort,
|
|
customRootId,
|
|
subfolder,
|
|
collectionId,
|
|
kind,
|
|
workflowOnly,
|
|
minRating,
|
|
minSizeMB,
|
|
maxSizeMB,
|
|
resolutionCompare,
|
|
minWidth,
|
|
minHeight,
|
|
maxWidth,
|
|
maxHeight,
|
|
workflowType,
|
|
dateRange,
|
|
dateExact,
|
|
// Treat any free-text search as "do not prefetch *" — the panel will
|
|
// run its own search query at mount time and we should not pollute
|
|
// the cache with mismatching results.
|
|
query: searchQuery ? "" : "*",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build a stable cache key matching the one used by `consumeEarlyFetch`
|
|
* callers. The key fingerprints every filter that affects /list output so a
|
|
* change in any filter invalidates the prefetch.
|
|
*/
|
|
function _buildEarlyFetchKey(ctx: any) {
|
|
return [
|
|
ctx.scope,
|
|
ctx.query || "*",
|
|
ctx.sort,
|
|
ctx.customRootId,
|
|
ctx.subfolder,
|
|
ctx.collectionId,
|
|
ctx.kind,
|
|
ctx.workflowOnly ? "1" : "",
|
|
ctx.minRating || "",
|
|
ctx.minSizeMB || "",
|
|
ctx.maxSizeMB || "",
|
|
ctx.resolutionCompare,
|
|
ctx.minWidth || "",
|
|
ctx.minHeight || "",
|
|
ctx.maxWidth || "",
|
|
ctx.maxHeight || "",
|
|
ctx.workflowType,
|
|
ctx.dateRange,
|
|
ctx.dateExact,
|
|
].join("|");
|
|
}
|
|
|
|
function _bindUnloadOnce() {
|
|
if (_pageHideBound) return;
|
|
_pageHideBound = true;
|
|
try {
|
|
// pagehide is fired both on unload and BFcache eviction; aborting the
|
|
// in-flight request avoids polluting the next session's network log
|
|
// with epoch-1 cancelled entries.
|
|
window.addEventListener("pagehide", () => {
|
|
try {
|
|
_earlyFetchAC?.abort?.();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
_earlyFetchAC = null;
|
|
_earlyFetchPromise = null;
|
|
_earlyFetchKey = null;
|
|
});
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start (or reuse) the early /list fetch. Safe to call multiple times.
|
|
* Returns the in-flight promise (or `null` when no window/localStorage).
|
|
*/
|
|
export function startEarlyFetch(): any {
|
|
if (!EARLY_FETCH_ENABLED) return null;
|
|
if (typeof window === "undefined") return null;
|
|
_bindUnloadOnce();
|
|
|
|
const ctx = _readPersistedListContext();
|
|
const key = _buildEarlyFetchKey(ctx);
|
|
const now = Date.now();
|
|
|
|
if (
|
|
_earlyFetchPromise &&
|
|
_earlyFetchKey === key &&
|
|
now - _earlyFetchTimestamp < EARLY_FETCH_TTL_MS
|
|
) {
|
|
return _earlyFetchPromise;
|
|
}
|
|
|
|
_earlyFetchKey = key;
|
|
_earlyFetchTimestamp = now;
|
|
|
|
try {
|
|
try {
|
|
_earlyFetchAC?.abort?.();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
_earlyFetchAC = typeof AbortController !== "undefined" ? new AbortController() : null;
|
|
|
|
// Skip prefetch only for contexts we cannot reliably reproduce here:
|
|
// - free-text searches (the panel will run its own search query)
|
|
// - collection scope (collectionId resolution is async-store-bound)
|
|
// - custom scope WITHOUT a persisted customRootId (we don't know
|
|
// which root to load; panel will pick one at mount time)
|
|
// Custom scope WITH a persisted customRootId is safe to prefetch:
|
|
// the listing endpoint accepts custom_root_id as a query param and
|
|
// the panel's match key includes it, so the cache will hit.
|
|
if (ctx.query !== "*" || ctx.scope === "collection") {
|
|
_earlyFetchPromise = null;
|
|
return null;
|
|
}
|
|
if (ctx.scope === "custom" && !ctx.customRootId) {
|
|
_earlyFetchPromise = null;
|
|
return null;
|
|
}
|
|
|
|
const url = buildListURL({
|
|
q: ctx.query || "*",
|
|
limit: APP_CONFIG?.DEFAULT_PAGE_SIZE || 200,
|
|
offset: 0,
|
|
scope: ctx.scope,
|
|
subfolder: ctx.subfolder || "",
|
|
customRootId: ctx.customRootId || null,
|
|
kind: ctx.kind || null,
|
|
hasWorkflow: ctx.workflowOnly ? true : null,
|
|
minRating: ctx.minRating > 0 ? ctx.minRating : null,
|
|
minSizeMB: ctx.minSizeMB > 0 ? ctx.minSizeMB : null,
|
|
maxSizeMB: ctx.maxSizeMB > 0 ? ctx.maxSizeMB : null,
|
|
resolutionCompare: ctx.resolutionCompare,
|
|
minWidth: ctx.minWidth > 0 ? ctx.minWidth : null,
|
|
minHeight: ctx.minHeight > 0 ? ctx.minHeight : null,
|
|
maxWidth: ctx.maxWidth > 0 ? ctx.maxWidth : null,
|
|
maxHeight: ctx.maxHeight > 0 ? ctx.maxHeight : null,
|
|
workflowType: ctx.workflowType || null,
|
|
dateRange: ctx.dateRange || null,
|
|
dateExact: ctx.dateExact || null,
|
|
sort: ctx.sort,
|
|
// Skip backend COUNT(*) on the prefetch — the panel will request
|
|
// it lazily on subsequent pages.
|
|
includeTotal: false,
|
|
});
|
|
_earlyFetchPromise = get(url, _earlyFetchAC ? { signal: _earlyFetchAC.signal } : {}).catch(
|
|
() => null,
|
|
);
|
|
} catch {
|
|
_earlyFetchPromise = null;
|
|
_earlyFetchAC = null;
|
|
}
|
|
return _earlyFetchPromise;
|
|
}
|
|
|
|
/**
|
|
* Consume the prefetched /list result if available and matching the key.
|
|
* `null` keys are accepted as "give me whatever was prefetched" (legacy).
|
|
*/
|
|
export function consumeEarlyFetch(key: string | null = null): any {
|
|
if (!EARLY_FETCH_ENABLED) return null;
|
|
if (!_earlyFetchPromise) return null;
|
|
if (key && _earlyFetchKey !== key) return null;
|
|
const now = Date.now();
|
|
if (now - _earlyFetchTimestamp >= EARLY_FETCH_TTL_MS) {
|
|
_earlyFetchPromise = null;
|
|
_earlyFetchKey = null;
|
|
return null;
|
|
}
|
|
const promise = _earlyFetchPromise;
|
|
_earlyFetchPromise = null;
|
|
_earlyFetchKey = null;
|
|
_earlyFetchAC = null;
|
|
return promise;
|
|
}
|
|
|
|
/**
|
|
* Returns the active prefetch key, or `null` if no fetch is in flight or it
|
|
* has expired. Callers can use this to compose grid-side cache lookups
|
|
* without consuming the promise.
|
|
*/
|
|
export function peekEarlyFetchKey(): string | null {
|
|
if (!EARLY_FETCH_ENABLED) return null;
|
|
if (!_earlyFetchPromise) return null;
|
|
if (Date.now() - _earlyFetchTimestamp >= EARLY_FETCH_TTL_MS) return null;
|
|
return _earlyFetchKey;
|
|
}
|
|
|
|
// Side-effect: kick off the very first /list request as soon as this module
|
|
// is parsed. Guarded by feature flag for tests/SSR. ENDPOINTS is touched only
|
|
// to ensure the import is not tree-shaken away.
|
|
try {
|
|
if (typeof window !== "undefined" && ENDPOINTS && ENDPOINTS.LIST) {
|
|
startEarlyFetch();
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|