UNPKG

cf-workers-query

Version:

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

497 lines (490 loc) 16 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/lib/hono.ts var hono_exports = {}; __export(hono_exports, { cache: () => cache }); module.exports = __toCommonJS(hono_exports); var import_http_exception = require("hono/http-exception"); // src/lib/create-query.ts var import_cloudflare_workers = require("cloudflare:workers"); // 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"), delete: /* @__PURE__ */ __name(async (_key) => { return false; }, "delete") }; }, "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 cache2 = await getCache(this.cacheName); const cacheKey = key instanceof URL ? key : this.buildCacheKey(key); const response = await cache2.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 maxAge = options?.maxAge ?? this.maxAge; const cacheKey = key instanceof URL ? key : this.buildCacheKey(key); if (value instanceof Response) { const body = await value.arrayBuffer(); const init = { status: value.status, statusText: value.statusText }; const isAlreadyCached = value.headers.get("cf-cache-status") === "HIT"; const currentCacheControl = value.headers.get("cache-control"); const cacheHeaders = new Headers(value.headers); cacheHeaders.set("cache-control", `max-age=${maxAge}`); cacheHeaders.set(HEADER_DATE, Date.now().toString()); if (!isAlreadyCached && currentCacheControl) { cacheHeaders.set(HEADER_CURRENT_CACHE_CONTROL, currentCacheControl); } const openCache2 = await getCache(this.cacheName); await openCache2.put(cacheKey, new Response(body, { ...init, headers: cacheHeaders })); return new Response(body, { ...init, headers: new Headers(value.headers) }); } const headers = new Headers(); headers.set("cache-control", `max-age=${maxAge}`); headers.set(HEADER, "true"); headers.set(HEADER_DATE, Date.now().toString()); const openCache = await getCache(this.cacheName); await openCache.put(cacheKey, new Response(JSON.stringify(value), { headers })); return value; } async delete(key) { const cache2 = await getCache(this.cacheName); const cacheKey = key instanceof URL ? key : this.buildCacheKey(key); await cache2.delete(cacheKey); } /** * 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 obj = o; const ctor = obj.constructor; if (ctor === void 0) { return true; } const prot = ctor.prototype; if (!hasObjectPrototype(prot)) { return false; } if (!Object.prototype.hasOwnProperty.call(prot, "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/dedupe-manager.ts var DedupeManager = class { static { __name(this, "DedupeManager"); } cache; constructor(cacheName = "cf-workers-query-dedup") { this.cache = new CacheApiAdaptor({ cacheName, maxAge: 10 }); } /** * Check if someone is already processing this key. * Best-effort: small race window exists between check and mark. */ async isProcessing(key) { const markerKey = this.buildMarkerKey(key); const existing = await this.cache.retrieve(markerKey); return !!existing?.data; } async markProcessing(key) { const markerKey = this.buildMarkerKey(key); await this.cache.update(markerKey, { ts: Date.now() }); } async clearProcessing(key) { const markerKey = this.buildMarkerKey(key); await this.cache.delete(markerKey); } buildMarkerKey(key) { if (key instanceof URL) return [ "dedup-marker", key.toString() ]; return [ "dedup-marker", ...key ]; } }; // src/lib/create-query.ts var dedupeManager = new DedupeManager(); 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 cache2 = new CacheApiAdaptor({ maxAge: gcTime, cacheName }); const cacheKey = queryKey; const invalidate = /* @__PURE__ */ __name(() => cache2.delete(cacheKey), "invalidate"); if (!revalidate && staleTime !== 0) { const cachedData = await cache2.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) { let alreadyRefreshing = false; try { alreadyRefreshing = await dedupeManager.isProcessing(cacheKey); } catch { } if (!alreadyRefreshing) { (0, import_cloudflare_workers.waitUntil)((async () => { await dedupeManager.markProcessing(cacheKey).catch(() => { }); try { const newData = await queryFn(); await cache2.update(cacheKey, newData); } finally { await dedupeManager.clearProcessing(cacheKey).catch(() => { }); } })()); } } } if (typeof enabled !== "function" || enabled(cachedData.data)) { return { data: cachedData.data, error: null, invalidate, lastModified: cachedData.lastModified }; } } } try { const alreadyProcessing = await dedupeManager.isProcessing(cacheKey); if (alreadyProcessing) { await new Promise((r) => setTimeout(r, 50)); const freshCache = await cache2.retrieve(cacheKey); if (freshCache?.data) { return { data: freshCache.data, error: null, invalidate, lastModified: freshCache.lastModified }; } } } catch { } try { await dedupeManager.markProcessing(cacheKey); } catch { } const { data: fetchedData, error } = await handleQueryFnWithRetry({ queryFn, retry, retryDelay, throwOnError }); let data = fetchedData; if (error) { (0, import_cloudflare_workers.waitUntil)(dedupeManager.clearProcessing(cacheKey).catch(() => { })); return { data: null, error, invalidate: /* @__PURE__ */ __name(() => void 0, "invalidate"), lastModified: null }; } if (typeof enabled !== "function" || enabled(data)) { if (data instanceof Response && data.body) { const chunks = []; let totalLength = 0; let pumpSuccess = false; let pumpResolve; const pumpDone = new Promise((r) => { pumpResolve = r; }); const reader = data.body.getReader(); const readable = new ReadableStream({ start(controller) { (async () => { try { while (true) { const { done, value } = await reader.read(); if (done) { controller.close(); pumpSuccess = true; break; } chunks.push(new Uint8Array(value)); totalLength += value.byteLength; controller.enqueue(value); } } catch { reader.releaseLock(); } pumpResolve(); })(); }, cancel() { reader.cancel().catch(() => { }); pumpResolve(); } }); const responseInit = { status: data.status, statusText: data.statusText, headers: data.headers }; (0, import_cloudflare_workers.waitUntil)(pumpDone.then(async () => { try { if (pumpSuccess) { const buffer = concatUint8Arrays(chunks, totalLength); await cache2.update(cacheKey, new Response(buffer, responseInit)); } } finally { await dedupeManager.clearProcessing(cacheKey).catch(() => { }); } })); data = new Response(readable, responseInit); } else { data = await cache2.update(cacheKey, data); (0, import_cloudflare_workers.waitUntil)(dedupeManager.clearProcessing(cacheKey).catch(() => { })); } } else { (0, import_cloudflare_workers.waitUntil)(dedupeManager.clearProcessing(cacheKey).catch(() => { })); } 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, failureCount: failureCount + 1, retryDelay, throwOnError }); } if (typeof retry === "function" && retry(failureCount + 1, e)) { await handleRetryDelay(failureCount, e, retryDelay); return handleQueryFnWithRetry({ queryFn, retry, failureCount: failureCount + 1, retryDelay, throwOnError }); } if (throwOnError) { throw e; } return { data: null, error: e }; } }, "handleQueryFnWithRetry"); function concatUint8Arrays(chunks, totalLength) { const result = new Uint8Array(totalLength); let offset = 0; for (const chunk of chunks) { result.set(chunk, offset); offset += chunk.byteLength; } return result.buffer; } __name(concatUint8Arrays, "concatUint8Arrays"); 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/hono.ts var cache = /* @__PURE__ */ __name(({ cacheKey, handler, revalidate, ...options }) => async (ctx) => { const { data: response, error } = await createQuery({ ...options, queryKey: typeof cacheKey === "function" ? cacheKey(ctx) : cacheKey, queryFn: /* @__PURE__ */ __name(() => Promise.resolve(handler(ctx)), "queryFn"), throwOnError: true, ...revalidate ? { revalidate: typeof revalidate === "boolean" ? revalidate : revalidate(ctx) } : {} }); if (!response || error) { throw new import_http_exception.HTTPException(500); } return response; }, "cache"); // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { cache });