rwsdk
Version:
Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime
295 lines (294 loc) • 10.2 kB
JavaScript
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;
}
}));
}