UNPKG

cf-workers-query

Version:

Automatically cache and revalidate data in Cloudflare Workers. Using the Cache API and Execution Context

344 lines (339 loc) 11.6 kB
var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __name = (target, value) => __defProp(target, "name", { value, configurable: true }); var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var src_exports = {}; __export(src_exports, { CacheApiAdaptor: () => CacheApiAdaptor, createQuery: () => createQuery, invalidateQuery: () => invalidateQuery }); module.exports = __toCommonJS(src_exports); // src/lib/cache-api.ts var CACHE_URL = "INTERNAL_CF_WORKERS_QUERY_CACHE_HOSTNAME.local"; var HEADER = "cf-workers-query"; var HEADER_DATE = "cf-workers-query-date"; var HEADER_CURRENT_CACHE_CONTROL = "cf-workers-query-current-cache-control"; var getVoidCache = /* @__PURE__ */ __name(() => { console.warn("No caches API available"); return { put: /* @__PURE__ */ __name(async (key, value) => { return; }, "put"), match: /* @__PURE__ */ __name(async (key) => { return void 0; }, "match") }; }, "getVoidCache"); var getCache = /* @__PURE__ */ __name(async (cacheName) => { if (!globalThis.caches) { return getVoidCache(); } return caches.open(cacheName); }, "getCache"); var CacheApiAdaptor = class { static { __name(this, "CacheApiAdaptor"); } cacheName; maxAge; constructor(ctx = {}) { this.cacheName = ctx.cacheName ?? "cf-workers-query-cache"; this.maxAge = ctx.maxAge ?? 60; } async retrieve(key) { try { const cache = await getCache(this.cacheName); const cacheKey = key instanceof URL ? key : this.buildCacheKey(key); const response = await cache.match(cacheKey); if (!response) { return null; } const createdResponse = response.headers.get(HEADER) === "true"; const cacheControlHeader = response.headers.get("cache-control"); const dateHeader = response.headers.get(HEADER_DATE); const data = !createdResponse ? new Response(response.body, response) : await response.json(); if (!createdResponse) { data.headers.delete(HEADER_DATE); data.headers.delete("cache-control"); const currentCacheControl = response.headers.get(HEADER_CURRENT_CACHE_CONTROL); if (currentCacheControl) { data.headers.set("cache-control", currentCacheControl); data.headers.delete(HEADER_CURRENT_CACHE_CONTROL); } } const lastModified = Number(dateHeader); const cacheControl = cacheControlHeader?.split("=")[1]; const maxAge = Number(cacheControl); return { data, lastModified: !isNaN(lastModified) ? lastModified : 0, maxAge: !isNaN(maxAge) ? maxAge : 0 }; } catch { return null; } } async update(key, value, options) { const cache = await getCache(this.cacheName); const maxAge = options?.maxAge ?? this.maxAge; const cacheKey = key instanceof URL ? key : this.buildCacheKey(key); if (value instanceof Response) { const response2 = new Response(value.body, value); const isAlreadyCached = response2.headers.get("cf-cache-status") === "HIT"; const currentCacheControl = value.headers.get("cache-control"); response2.headers.set("cache-control", `max-age=${maxAge}`); response2.headers.set(HEADER_DATE, Date.now().toString()); if (!isAlreadyCached && currentCacheControl) { response2.headers.set(HEADER_CURRENT_CACHE_CONTROL, currentCacheControl); } await cache.put(cacheKey, response2); return; } const headers = new Headers(); headers.set("cache-control", `max-age=${maxAge}`); headers.set(HEADER, "true"); headers.set(HEADER_DATE, Date.now().toString()); const response = new Response(JSON.stringify(value), { headers }); await cache.put(cacheKey, response); } async delete(key) { const cache = await getCache(this.cacheName); const response = new Response(null, { headers: new Headers({ "cache-control": `max-age=0` }) }); const cacheKey = key instanceof URL ? key : this.buildCacheKey(key); await cache.put(cacheKey, response); } /** * Builds the full cache key for the suspense cache. * * @param key Key for the item in the suspense cache. * @returns The fully-formed cache key for the suspense cache. */ buildCacheKey(key) { return `https://${CACHE_URL}/entry?key=${hashKey(key)}`; } }; function isPlainObject(o) { if (!hasObjectPrototype(o)) { return false; } const ctor = o.constructor; if (ctor === void 0) { return true; } const prot = ctor.prototype; if (!hasObjectPrototype(prot)) { return false; } if (!prot.hasOwnProperty("isPrototypeOf")) { return false; } if (Object.getPrototypeOf(o) !== Object.prototype) { return false; } return true; } __name(isPlainObject, "isPlainObject"); function hasObjectPrototype(o) { return Object.prototype.toString.call(o) === "[object Object]"; } __name(hasObjectPrototype, "hasObjectPrototype"); function hashKey(queryKey) { return JSON.stringify(queryKey, (_, val) => isPlainObject(val) ? Object.keys(val).sort().reduce((result, key) => { result[key] = val[key]; return result; }, {}) : val); } __name(hashKey, "hashKey"); // src/lib/create-query.ts var import_cloudflare_workers = require("cloudflare:workers"); var import_nanoid = require("nanoid"); var createQuery = /* @__PURE__ */ __name(async ({ queryKey, queryFn, gcTime, staleTime, revalidate, retry, retryDelay, cacheName, throwOnError, enabled = true, revalidateMode = "default" }) => { try { if (!queryKey || !enabled || !gcTime) { const { data: data2, error: error2 } = await handleQueryFnWithRetry({ queryFn, retry, retryDelay, throwOnError }); return { data: data2, error: error2, invalidate: /* @__PURE__ */ __name(() => void 0, "invalidate"), lastModified: null }; } const cache = new CacheApiAdaptor({ maxAge: gcTime, cacheName }); const cacheKey = queryKey; const invalidate = /* @__PURE__ */ __name(() => cache.delete(cacheKey), "invalidate"); if (!revalidate && staleTime !== 0) { const cachedData = await cache.retrieve(cacheKey); if (cachedData?.data) { const isStale = staleTime && cachedData.lastModified + staleTime * 1e3 < Date.now(); if (isStale) { const shouldRevalidate = revalidateMode === "probabilistic" ? shouldRevalidateByProbability(cachedData.lastModified, cachedData.maxAge) : true; if (shouldRevalidate) { const staleId = (0, import_nanoid.nanoid)(); const dedupeKey = cacheKey instanceof URL ? new URL(cacheKey) : [ ...cacheKey, "dedupe" ]; if (dedupeKey instanceof URL) { dedupeKey.searchParams.set("dedupe", "true"); } await cache.update(dedupeKey, staleId, { maxAge: 60 }); const refreshFunc = /* @__PURE__ */ __name(async () => { const { data: cachedStaleId } = await cache.retrieve(dedupeKey) ?? {}; if (cachedStaleId && cachedStaleId !== staleId) { return; } const newData = await queryFn(); await cache.update(cacheKey, newData); }, "refreshFunc"); (0, import_cloudflare_workers.waitUntil)(refreshFunc()); } } if (!isStale) { if (typeof enabled !== "function" || enabled(cachedData.data)) { return { data: cachedData.data, error: null, invalidate, lastModified: cachedData.lastModified }; } } } } const { data, error } = await handleQueryFnWithRetry({ queryFn, retry, retryDelay, throwOnError }); if (error) { return { data: null, error, invalidate: /* @__PURE__ */ __name(() => void 0, "invalidate"), lastModified: null }; } if (typeof enabled !== "function" || enabled(data)) { const cacheData = data instanceof Response ? data.clone() : data; (0, import_cloudflare_workers.waitUntil)(cache.update(cacheKey, cacheData)); } return { data, error: null, invalidate, lastModified: null }; } catch (e) { if (throwOnError) { throw e; } return { data: null, error: e, invalidate: /* @__PURE__ */ __name(() => void 0, "invalidate"), lastModified: null }; } }, "createQuery"); var defaultRetryDelay = /* @__PURE__ */ __name((attemptIndex) => Math.min(1e3 * 2 ** attemptIndex, 3e4), "defaultRetryDelay"); function handleRetryDelay(failureCount, error, retryDelay = defaultRetryDelay) { const timeMs = typeof retryDelay === "function" ? retryDelay(failureCount + 1, error) : retryDelay; return new Promise((resolve) => { setTimeout(resolve, timeMs); }); } __name(handleRetryDelay, "handleRetryDelay"); var handleQueryFnWithRetry = /* @__PURE__ */ __name(async ({ queryFn, retry = 0, failureCount = 0, retryDelay, throwOnError }) => { try { const data = await queryFn(); return { data, error: null }; } catch (e) { if (typeof retry === "number" && retry > 0) { await handleRetryDelay(failureCount, e, retryDelay); return handleQueryFnWithRetry({ queryFn, retry: retry - 1, retryDelay }); } if (typeof retry === "function" && retry(failureCount + 1, e)) { await handleRetryDelay(failureCount, e, retryDelay); return handleQueryFnWithRetry({ queryFn, retry, failureCount: failureCount + 1, retryDelay }); } if (throwOnError) { throw e; } return { data: null, error: e }; } }, "handleQueryFnWithRetry"); function shouldRevalidateByProbability(lastModified, maxAge) { const expirationDate = new Date(lastModified + maxAge * 1e3); const remainingCacheTimeInS = (expirationDate.getTime() - Date.now()) / 1e3; const cacheRevalidationIntervalInS = maxAge; if (remainingCacheTimeInS > cacheRevalidationIntervalInS) { return false; } if (remainingCacheTimeInS <= 0) { return true; } const revalidationSteepness = 1 / cacheRevalidationIntervalInS; return Math.random() > Math.exp(-revalidationSteepness * (cacheRevalidationIntervalInS - remainingCacheTimeInS)); } __name(shouldRevalidateByProbability, "shouldRevalidateByProbability"); // src/lib/invalidate-query.ts var invalidateQuery = /* @__PURE__ */ __name(({ queryKey, cacheName }) => { const cache = new CacheApiAdaptor({ cacheName }); return cache.delete(queryKey); }, "invalidateQuery"); // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { CacheApiAdaptor, createQuery, invalidateQuery });