335 lines
11 KiB
TypeScript
335 lines
11 KiB
TypeScript
import type { QueryKey } from "@tanstack/react-query";
|
|
import type { PersistedClient, Persister } from "@tanstack/react-query-persist-client";
|
|
import { parse, stringify } from "superjson";
|
|
|
|
import {
|
|
isPersistableDashboardQueryKey,
|
|
isTrpcForbiddenError,
|
|
queryCacheDefaultGcTimeMs,
|
|
} from "@homarr/api/query-cache";
|
|
|
|
export const queryPersistenceMaxAgeMs = queryCacheDefaultGcTimeMs;
|
|
export const queryPersistenceMaxDataBytes = 256 * 1024;
|
|
export const queryPersistenceMaxCacheBytes = 2 * 1024 * 1024;
|
|
export const queryPersistenceThrottleMs = 1_000;
|
|
export const queryPersistenceIdleTimeoutMs = 1_000;
|
|
export const queryPersistenceBuster = "v4-public-data-only";
|
|
|
|
const queryPersistenceStoragePrefix = "homarr:widget-query-cache";
|
|
|
|
export const getQueryPersistenceStorageKey = (scope: string | null) =>
|
|
`${queryPersistenceStoragePrefix}:${encodeURIComponent(scope ?? "anonymous")}`;
|
|
|
|
interface PersistableQuery {
|
|
queryKey: QueryKey;
|
|
state: {
|
|
data: unknown;
|
|
dataUpdatedAt: number;
|
|
error?: unknown;
|
|
};
|
|
}
|
|
|
|
export type SessionQueryPersister = Persister & { flush: () => void; stop: () => void };
|
|
|
|
const textEncoder = new TextEncoder();
|
|
const budgetVerdictCache = new WeakMap<object, boolean>();
|
|
|
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
|
|
const isQuotaExceededError = (error: unknown): boolean => {
|
|
if (!isRecord(error)) return false;
|
|
return (
|
|
error.name === "QuotaExceededError" ||
|
|
error.name === "NS_ERROR_DOM_QUOTA_REACHED" ||
|
|
error.code === 22 ||
|
|
error.code === 1014
|
|
);
|
|
};
|
|
|
|
const measureFitsBudget = (data: unknown) => {
|
|
try {
|
|
return textEncoder.encode(stringify(data)).byteLength <= queryPersistenceMaxDataBytes;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const measureSerializedBytes = (data: unknown) => textEncoder.encode(stringify(data)).byteLength;
|
|
|
|
const fitsStorageBudget = (data: unknown) => {
|
|
if (typeof data !== "object" || data === null) return measureFitsBudget(data);
|
|
|
|
const cached = budgetVerdictCache.get(data);
|
|
if (cached !== undefined) return cached;
|
|
|
|
const verdict = measureFitsBudget(data);
|
|
budgetVerdictCache.set(data, verdict);
|
|
return verdict;
|
|
};
|
|
|
|
const holdsDashboardData = (query: PersistableQuery) =>
|
|
query.state.data !== undefined &&
|
|
Number.isFinite(query.state.dataUpdatedAt) &&
|
|
query.state.dataUpdatedAt > 0 &&
|
|
isPersistableDashboardQueryKey(query.queryKey);
|
|
|
|
export const shouldPersistDashboardQuery = (query: PersistableQuery) =>
|
|
holdsDashboardData(query) && !isTrpcForbiddenError(query.state.error) && fitsStorageBudget(query.state.data);
|
|
|
|
type DehydratedQuery = PersistedClient["clientState"]["queries"][number];
|
|
|
|
const keepNewestQueries = (client: PersistedClient, maximumQueries: number): PersistedClient | undefined => {
|
|
if (maximumQueries <= 0) return undefined;
|
|
if (client.clientState.queries.length <= maximumQueries) return client;
|
|
|
|
return {
|
|
...client,
|
|
clientState: {
|
|
...client.clientState,
|
|
queries: client.clientState.queries
|
|
.toSorted((left, right) => right.state.dataUpdatedAt - left.state.dataUpdatedAt)
|
|
.slice(0, maximumQueries),
|
|
},
|
|
};
|
|
};
|
|
|
|
const sanitizePersistedQuery = (query: DehydratedQuery): DehydratedQuery => ({
|
|
...query,
|
|
state: {
|
|
...query.state,
|
|
error: null,
|
|
errorUpdateCount: 0,
|
|
errorUpdatedAt: 0,
|
|
fetchFailureCount: 0,
|
|
fetchFailureReason: null,
|
|
fetchStatus: "idle",
|
|
isInvalidated: true,
|
|
status: "success",
|
|
},
|
|
});
|
|
|
|
const sanitizePersistedClient = (client: PersistedClient): PersistedClient => ({
|
|
...client,
|
|
clientState: {
|
|
mutations: [],
|
|
queries: client.clientState.queries
|
|
.filter((query) => holdsDashboardData(query) && !isTrpcForbiddenError(query.state.error))
|
|
.map(sanitizePersistedQuery),
|
|
},
|
|
});
|
|
|
|
const isRestorableQuery = (value: unknown): value is DehydratedQuery => {
|
|
if (!isRecord(value) || typeof value.queryHash !== "string" || !Array.isArray(value.queryKey)) return false;
|
|
if (!isRecord(value.state) || typeof value.state.status !== "string") return false;
|
|
|
|
return holdsDashboardData(value as unknown as PersistableQuery);
|
|
};
|
|
|
|
const sanitizeRestoredClient = (value: unknown): PersistedClient | undefined => {
|
|
if (!isRecord(value) || !Number.isFinite(value.timestamp) || typeof value.buster !== "string") return undefined;
|
|
if (!isRecord(value.clientState) || !Array.isArray(value.clientState.queries)) return undefined;
|
|
|
|
return {
|
|
timestamp: value.timestamp as number,
|
|
buster: value.buster,
|
|
clientState: {
|
|
mutations: [],
|
|
queries: value.clientState.queries.filter(isRestorableQuery).map(sanitizePersistedQuery),
|
|
},
|
|
};
|
|
};
|
|
|
|
const getSessionStorage = (): Storage | undefined => {
|
|
if (typeof window === "undefined") return undefined;
|
|
|
|
try {
|
|
return window.sessionStorage;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
const removeStorageItem = (storage: Storage | undefined, key: string) => {
|
|
try {
|
|
storage?.removeItem(key);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
const removeForeignScopes = (storage: Storage | undefined, activeKey: string) => {
|
|
if (!storage) return;
|
|
|
|
try {
|
|
const foreignKeys: string[] = [];
|
|
for (let index = 0; index < storage.length; index += 1) {
|
|
const storedKey = storage.key(index);
|
|
if (storedKey === null || storedKey === activeKey) continue;
|
|
if (storedKey.startsWith(`${queryPersistenceStoragePrefix}:`)) foreignKeys.push(storedKey);
|
|
}
|
|
|
|
for (const foreignKey of foreignKeys) storage.removeItem(foreignKey);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
export const createSessionQueryPersister = (
|
|
scope: string | null,
|
|
storage: Storage | undefined = getSessionStorage(),
|
|
): SessionQueryPersister => {
|
|
const key = getQueryPersistenceStorageKey(scope);
|
|
removeForeignScopes(storage, key);
|
|
let pendingClient: PersistedClient | undefined;
|
|
let persistDelayTimer: ReturnType<typeof setTimeout> | undefined;
|
|
let persistIdleCallback: number | undefined;
|
|
let persistedQueryLimit = Number.POSITIVE_INFINITY;
|
|
let persistenceDisabled = false;
|
|
let stopped = false;
|
|
|
|
const trimToQueryLimit = (client: PersistedClient): PersistedClient | undefined => {
|
|
const limitedClient = keepNewestQueries(client, persistedQueryLimit);
|
|
if (!limitedClient || measureSerializedBytes(limitedClient) <= queryPersistenceMaxCacheBytes) return limitedClient;
|
|
|
|
const emptyClient: PersistedClient = {
|
|
...limitedClient,
|
|
clientState: { ...limitedClient.clientState, queries: [] },
|
|
};
|
|
let remainingBytes = queryPersistenceMaxCacheBytes - measureSerializedBytes(emptyClient);
|
|
const selectedQueries: DehydratedQuery[] = [];
|
|
const newestQueries = limitedClient.clientState.queries.toSorted(
|
|
(left, right) => right.state.dataUpdatedAt - left.state.dataUpdatedAt,
|
|
);
|
|
|
|
for (const query of newestQueries) {
|
|
const queryBytes = measureSerializedBytes(query) + 1;
|
|
if (queryBytes > remainingBytes) continue;
|
|
selectedQueries.push(query);
|
|
remainingBytes -= queryBytes;
|
|
}
|
|
|
|
const buildCandidate = (queryCount: number): PersistedClient => ({
|
|
...limitedClient,
|
|
clientState: { ...limitedClient.clientState, queries: selectedQueries.slice(0, queryCount) },
|
|
});
|
|
let minimumQueryCount = 0;
|
|
let maximumQueryCount = selectedQueries.length;
|
|
|
|
while (minimumQueryCount < maximumQueryCount) {
|
|
const queryCount = Math.ceil((minimumQueryCount + maximumQueryCount) / 2);
|
|
const candidate = buildCandidate(queryCount);
|
|
if (measureSerializedBytes(candidate) <= queryPersistenceMaxCacheBytes) {
|
|
minimumQueryCount = queryCount;
|
|
} else {
|
|
maximumQueryCount = queryCount - 1;
|
|
}
|
|
}
|
|
|
|
const candidate = buildCandidate(minimumQueryCount);
|
|
if (measureSerializedBytes(candidate) > queryPersistenceMaxCacheBytes) return undefined;
|
|
return candidate;
|
|
};
|
|
|
|
const persistNow = (client: PersistedClient) => {
|
|
if (!storage || persistenceDisabled) return;
|
|
let candidate: PersistedClient | undefined;
|
|
try {
|
|
const sanitizedClient = sanitizePersistedClient(client);
|
|
candidate = trimToQueryLimit(sanitizedClient);
|
|
} catch {
|
|
persistenceDisabled = true;
|
|
removeStorageItem(storage, key);
|
|
return;
|
|
}
|
|
if (!candidate || candidate.clientState.queries.length === 0) {
|
|
removeStorageItem(storage, key);
|
|
return;
|
|
}
|
|
|
|
let quotaLimited = false;
|
|
while (candidate) {
|
|
try {
|
|
storage.setItem(key, stringify(candidate));
|
|
if (quotaLimited) {
|
|
persistedQueryLimit = Math.min(persistedQueryLimit, candidate.clientState.queries.length);
|
|
}
|
|
return;
|
|
} catch (error) {
|
|
removeStorageItem(storage, key);
|
|
if (!isQuotaExceededError(error)) {
|
|
persistenceDisabled = true;
|
|
return;
|
|
}
|
|
quotaLimited = true;
|
|
candidate = keepNewestQueries(candidate, Math.floor(candidate.clientState.queries.length / 2));
|
|
if (!candidate) return;
|
|
}
|
|
}
|
|
};
|
|
|
|
const clearScheduledPersist = () => {
|
|
if (persistDelayTimer !== undefined) {
|
|
clearTimeout(persistDelayTimer);
|
|
persistDelayTimer = undefined;
|
|
}
|
|
if (persistIdleCallback !== undefined && typeof window !== "undefined") {
|
|
window.cancelIdleCallback?.(persistIdleCallback);
|
|
persistIdleCallback = undefined;
|
|
}
|
|
};
|
|
|
|
const flush = () => {
|
|
clearScheduledPersist();
|
|
const client = pendingClient;
|
|
pendingClient = undefined;
|
|
if (client) persistNow(client);
|
|
};
|
|
|
|
const scheduleIdlePersist = () => {
|
|
persistDelayTimer = undefined;
|
|
if (typeof window !== "undefined" && window.requestIdleCallback) {
|
|
persistIdleCallback = window.requestIdleCallback(flush, { timeout: queryPersistenceIdleTimeoutMs });
|
|
return;
|
|
}
|
|
flush();
|
|
};
|
|
|
|
return {
|
|
persistClient(client) {
|
|
if (!storage || stopped || persistenceDisabled) return;
|
|
pendingClient = client;
|
|
if (persistDelayTimer !== undefined || persistIdleCallback !== undefined) return;
|
|
persistDelayTimer = setTimeout(scheduleIdlePersist, queryPersistenceThrottleMs);
|
|
},
|
|
restoreClient() {
|
|
try {
|
|
const storedValue = storage?.getItem(key);
|
|
if (!storedValue) return undefined;
|
|
|
|
const client = sanitizeRestoredClient(parse<unknown>(storedValue));
|
|
if (!client || client.timestamp > Date.now() || client.clientState.queries.length === 0) {
|
|
removeStorageItem(storage, key);
|
|
return undefined;
|
|
}
|
|
|
|
return client;
|
|
} catch {
|
|
removeStorageItem(storage, key);
|
|
return undefined;
|
|
}
|
|
},
|
|
removeClient() {
|
|
clearScheduledPersist();
|
|
pendingClient = undefined;
|
|
removeStorageItem(storage, key);
|
|
},
|
|
flush,
|
|
stop() {
|
|
stopped = true;
|
|
clearScheduledPersist();
|
|
pendingClient = undefined;
|
|
},
|
|
};
|
|
};
|