ghost-io
Version:
Invisible Background Data Fetching & Prefetching library that uses heuristics (hover, scroll, idle) to speed up your SPA or dashboard.
230 lines (208 loc) • 6.64 kB
text/typescript
import axios, {
AxiosInstance,
AxiosResponse,
InternalAxiosRequestConfig,
} from "axios";
import { GhostIOConfig, AxiosIntegrationOptions } from "../index.js";
interface InternalConfig {
maxCacheSize: number;
prefetchOnHover: boolean;
prefetchOnScroll: boolean;
idlePrefetchDelay: number;
concurrencyLimit: number;
}
const defaultConfig: InternalConfig = {
maxCacheSize: 50,
prefetchOnHover: true,
prefetchOnScroll: true,
idlePrefetchDelay: 5000,
concurrencyLimit: 3,
};
export class GhostIO {
private config: InternalConfig;
private cache: Map<string, any>;
private inFlight: Set<string>;
private axiosRegisteredInstances: Set<any>;
private currentRequestsCount = 0;
constructor(userConfig?: GhostIOConfig) {
this.config = { ...defaultConfig, ...userConfig };
this.cache = new Map();
this.inFlight = new Set();
this.axiosRegisteredInstances = new Set();
console.log("[GhostIO] Initialized with config:", this.config);
this.initEventListeners();
}
registerAxios(options: AxiosIntegrationOptions): void {
const { instance } = options;
if (!instance) {
console.warn("[GhostIO] No Axios instance provided.");
return;
}
if (this.axiosRegisteredInstances.has(instance)) {
console.log("[GhostIO] Axios instance already registered.");
return;
}
this.axiosRegisteredInstances.add(instance);
// Intercept requests
instance.interceptors.request.use(
async (config: any) => {
const url = config.url || "";
// If already cached, short-circuit the request
if (this.cache.has(url)) {
console.log(
`[GhostIO] Short-circuiting axios request from cache: ${url}`,
);
const fakeResponse = {
data: this.cache.get(url),
status: 200,
statusText: "OK",
headers: {},
config,
__ghostIOCache__: true,
};
return Promise.reject(fakeResponse);
}
return config;
},
(error: any) => Promise.reject(error),
);
// Intercept responses
instance.interceptors.response.use(
(response: any) => {
if (!response.__ghostIOCache__) {
this.storeInCache(response.config.url, response.data);
}
return response;
},
(error: any) => {
if (error && error.__ghostIOCache__) {
return Promise.resolve(error);
}
return Promise.reject(error);
},
);
console.log("[GhostIO] Axios integrated successfully.");
}
async prefetch(url: string): Promise<void> {
// Use the input URL as-is (user must provide full URL)
const finalURL = url;
if (this.cache.has(finalURL)) {
console.log(`[GhostIO] Prefetch skipped; already in cache: ${finalURL}`);
return;
}
if (this.inFlight.has(finalURL)) {
console.log(`[GhostIO] Prefetch skipped; already in-flight: ${finalURL}`);
return;
}
if (this.currentRequestsCount >= this.config.concurrencyLimit) {
console.log(
"[GhostIO] Concurrency limit reached; deferring prefetch:",
finalURL,
);
return;
}
console.log("[GhostIO] Prefetching:", finalURL);
this.inFlight.add(finalURL);
this.currentRequestsCount++;
try {
const response = await axios.get(finalURL);
this.storeInCache(finalURL, response.data);
console.log("[GhostIO] Prefetch succeeded for:", finalURL);
} catch (err) {
console.warn("[GhostIO] Failed to prefetch:", finalURL, err);
} finally {
this.inFlight.delete(finalURL);
this.currentRequestsCount--;
}
}
get(url: string) {
const finalURL = url;
return this.cache.has(finalURL) ? this.cache.get(finalURL) : null;
}
clearCache(): void {
console.log("[GhostIO] Clearing entire cache.");
this.cache.clear();
}
private storeInCache(url: string, data: any) {
this.cache.set(url, data);
console.log("[GhostIO] Stored in cache:", url);
if (this.cache.size > this.config.maxCacheSize) {
const oldestKey = this.cache.keys().next().value;
// @ts-ignore
this.cache.delete(oldestKey);
console.log(
"[GhostIO] Cache exceeded max size; removed oldest entry:",
oldestKey,
);
}
}
private initEventListeners() {
if (typeof document === "undefined") {
console.warn("[GhostIO] No DOM detected; ignoring hover/scroll events.");
return;
}
// Prefetch on hover
if (this.config.prefetchOnHover) {
document.addEventListener("mouseover", this.onHover.bind(this));
console.log("[GhostIO] Hover prefetch event listener attached.");
}
// Prefetch on scroll
if (this.config.prefetchOnScroll) {
document.addEventListener("scroll", this.onScroll.bind(this));
console.log("[GhostIO] Scroll prefetch event listener attached.");
}
// Idle prefetch
if (this.config.idlePrefetchDelay > 0) {
let idleTimeout: NodeJS.Timeout;
const resetTimer = () => {
clearTimeout(idleTimeout);
idleTimeout = setTimeout(
() => this.idlePrefetch(),
this.config.idlePrefetchDelay,
);
};
document.addEventListener("mousemove", resetTimer);
document.addEventListener("keypress", resetTimer);
document.addEventListener("scroll", resetTimer);
resetTimer();
console.log("[GhostIO] Idle prefetch event listeners attached.");
}
}
private onHover(e: MouseEvent) {
const target = (e.target as HTMLElement)?.closest("[data-prefetch]");
if (!target) return;
const url = target.getAttribute("data-prefetch");
if (url) {
console.log(
"[GhostIO] Detected hover on element with data-prefetch:",
url,
);
this.prefetch(url);
}
}
private onScroll() {
const elements = document.querySelectorAll("[data-prefetch]");
elements.forEach((el) => {
const rect = el.getBoundingClientRect();
if (rect.top < window.innerHeight * 1.5) {
const url = el.getAttribute("data-prefetch");
if (url) {
console.log(
"[GhostIO] Element in view during scroll with data-prefetch:",
url,
);
this.prefetch(url);
}
}
});
}
private idlePrefetch() {
console.log(
"[GhostIO] User idle detected; running background prefetch tasks.",
);
document.querySelectorAll("[data-prefetch]").forEach((el) => {
const url = el.getAttribute("data-prefetch");
if (url) this.prefetch(url);
});
}
}