UNPKG

rwsdk

Version:

Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime

295 lines (294 loc) 10.2 kB
const TAB_ID_STORAGE_KEY = "rwsdk-navigation-tab-id"; const BUILD_ID = "rwsdk"; // Stable build identifier let cacheState = null; function getOrInitializeCacheState() { if (cacheState) { return cacheState; } // Get or generate tabId let tabId; if (typeof window !== "undefined" && window.sessionStorage) { try { const stored = sessionStorage.getItem(TAB_ID_STORAGE_KEY); if (stored) { tabId = stored; } else { tabId = crypto.randomUUID(); sessionStorage.setItem(TAB_ID_STORAGE_KEY, tabId); } } catch { // Fallback to in-memory tabId if sessionStorage is unavailable tabId = crypto.randomUUID(); } } else { // Fallback for non-browser environments tabId = crypto.randomUUID(); } cacheState = { tabId, generation: 0, buildId: BUILD_ID, }; return cacheState; } function getCurrentCacheName() { const state = getOrInitializeCacheState(); return `rsc-prefetch:${state.buildId}:${state.tabId}:${state.generation}`; } function incrementGeneration() { const state = getOrInitializeCacheState(); state.generation++; return state.generation; } function getCurrentGeneration() { const state = getOrInitializeCacheState(); return state.generation; } function getTabId() { const state = getOrInitializeCacheState(); return state.tabId; } function getBuildId() { const state = getOrInitializeCacheState(); return state.buildId; } function getBrowserNavigationCacheEnvironment() { if (typeof window === "undefined") { return undefined; } return { isSecureContext: window.isSecureContext, origin: window.location.origin, // CacheStorage is only available in secure contexts in supporting browsers. caches: "caches" in window ? window.caches : undefined, fetch: window.fetch.bind(window), }; } /** * Creates a default NavigationCacheStorage implementation that wraps the browser's CacheStorage API. * This maintains the current generation-based cache naming and eviction logic. */ export function createDefaultNavigationCacheStorage(env) { const runtimeEnv = env ?? getBrowserNavigationCacheEnvironment(); if (!runtimeEnv) { return undefined; } const { caches } = runtimeEnv; if (!caches) { return undefined; } return { async open(cacheName) { const cache = await caches.open(cacheName); return { async put(request, response) { await cache.put(request, response); }, async match(request) { return (await cache.match(request)) ?? undefined; }, }; }, async delete(cacheName) { return await caches.delete(cacheName); }, async keys() { return await caches.keys(); }, }; } /** * Preloads the RSC navigation response for a given URL into the Cache API. * * This issues a GET request with the `__rsc` query parameter set, and, on a * successful response, stores it in a versioned Cache using `cache.put`. * * See MDN for Cache interface semantics: * https://developer.mozilla.org/en-US/docs/Web/API/Cache */ export async function preloadNavigationUrl(rawUrl, env, cacheStorage) { const runtimeEnv = env ?? getBrowserNavigationCacheEnvironment(); if (!runtimeEnv) { return; } const { origin, fetch } = runtimeEnv; // Use provided cacheStorage or create default one const storage = cacheStorage ?? createDefaultNavigationCacheStorage(runtimeEnv); // CacheStorage may be evicted by the browser at any time. We treat it as a // best-effort optimization. if (!storage) { return; } try { const url = rawUrl instanceof URL ? new URL(rawUrl.toString()) : new URL(rawUrl, origin); if (url.origin !== origin) { // Only cache same-origin navigations. return; } // Ensure we are fetching the RSC navigation response. url.searchParams.set("__rsc", ""); const request = new Request(url.toString(), { method: "GET", redirect: "manual", }); const cacheName = getCurrentCacheName(); const cache = await storage.open(cacheName); const response = await fetch(request); // Avoid caching obvious error responses; browsers may still evict entries // at any time, see MDN Cache docs for details. if (response.status >= 400) { return; } await cache.put(request, response.clone()); } catch (error) { // Best-effort optimization; never let cache failures break navigation. return; } } /** * Attempts to retrieve a cached navigation response for the given URL. * * Returns the cached Response if found, or undefined if not cached or if * CacheStorage is unavailable. */ export async function getCachedNavigationResponse(rawUrl, env, cacheStorage) { const runtimeEnv = env ?? getBrowserNavigationCacheEnvironment(); if (!runtimeEnv) { return undefined; } const { origin } = runtimeEnv; // Use provided cacheStorage, check global, or create default one let storage = cacheStorage; if (!storage && typeof globalThis !== "undefined") { storage = globalThis.__rsc_cacheStorage; } storage = storage ?? createDefaultNavigationCacheStorage(runtimeEnv); if (!storage) { return undefined; } try { const url = rawUrl instanceof URL ? new URL(rawUrl.toString()) : new URL(rawUrl, origin); if (url.origin !== origin) { // Only cache same-origin navigations. return undefined; } // Ensure we are matching the RSC navigation response. url.searchParams.set("__rsc", ""); const request = new Request(url.toString(), { method: "GET", redirect: "manual", }); const cacheName = getCurrentCacheName(); const cache = await storage.open(cacheName); const cachedResponse = await cache.match(request); return cachedResponse ?? undefined; } catch (error) { // Best-effort optimization; never let cache failures break navigation. return undefined; } } /** * Cleans up old generation caches for the current tab. * * This should be called after navigation commits to evict cache entries from * previous navigations. It runs asynchronously via requestIdleCallback or * setTimeout to avoid blocking the critical path. */ export async function evictOldGenerationCaches(env, cacheStorage) { const runtimeEnv = env ?? getBrowserNavigationCacheEnvironment(); if (!runtimeEnv) { return; } // Use provided cacheStorage or create default one const storage = cacheStorage ?? createDefaultNavigationCacheStorage(runtimeEnv); if (!storage) { return; } const currentGeneration = getCurrentGeneration(); const tabId = getTabId(); const buildId = getBuildId(); // Schedule cleanup in idle time to avoid blocking navigation const cleanup = async () => { try { // List all cache names const cacheNames = await storage.keys(); const prefix = `rsc-prefetch:${buildId}:${tabId}:`; // Find all caches for this tab const tabCaches = cacheNames.filter((name) => name.startsWith(prefix)); // Delete caches with generation numbers less than current const deletePromises = tabCaches.map((cacheName) => { const match = cacheName.match(new RegExp(`${prefix}(\\d+)$`)); if (match) { const generation = parseInt(match[1], 10); if (generation < currentGeneration) { return storage.delete(cacheName); } } return Promise.resolve(false); }); await Promise.all(deletePromises); } catch (error) { // Best-effort cleanup; never let failures break navigation. } }; // Use requestIdleCallback if available, otherwise setTimeout if (typeof requestIdleCallback !== "undefined") { requestIdleCallback(cleanup, { timeout: 5000 }); } else { setTimeout(cleanup, 0); } } /** * Increments the generation counter and schedules cleanup of old caches. * * This should be called after navigation commits to mark the current generation * as complete and prepare for the next navigation cycle. */ export function onNavigationCommit(env, cacheStorage) { incrementGeneration(); void evictOldGenerationCaches(env, cacheStorage); } /** * Scan the document for `<link rel="prefetch" href="...">` elements that point * to same-origin paths and prefetch their RSC navigation responses into the * Cache API. * * This is invoked after client navigations to warm the navigation cache in * the background. We intentionally keep Cache usage write-only for now; reads * still go through the normal fetch path. */ export async function preloadFromLinkTags(doc = document, env, cacheStorage) { if (typeof doc === "undefined") { return; } const links = Array.from(doc.querySelectorAll('link[rel="prefetch"][href]')); await Promise.all(links.map((link) => { const href = link.getAttribute("href"); if (!href) { return; } // Treat paths that start with "/" as route-like; assets (e.g. .js, .css) // are already handled by the existing modulepreload pipeline. if (!href.startsWith("/")) { return; } try { const url = new URL(href, env?.origin ?? window.location.origin); return preloadNavigationUrl(url, env, cacheStorage); } catch { return; } })); }