UNPKG

cf-workers-query

Version:

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

567 lines (559 loc) 17 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); // src/lib/create-query.ts var import_cloudflare_workers = require("cloudflare:workers"); var import_nanoid = require("nanoid"); // 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 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 cache2 = 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 cache2.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 cache2.put(cacheKey, response); } async delete(key) { const cache2 = 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 cache2.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/dedupe-manager.ts var DedupeManager = class { static { __name(this, "DedupeManager"); } CACHE_LOCK_TTL = 5; lockCache; resultCache; constructor() { this.lockCache = new CacheApiAdaptor({ cacheName: "cf-workers-query-locks", maxAge: this.CACHE_LOCK_TTL }); this.resultCache = new CacheApiAdaptor({ cacheName: "cf-workers-query-results", maxAge: this.CACHE_LOCK_TTL }); } /** * Deduplicate async function execution * * If the same key is requested by multiple workers/requests concurrently: * 1. First request acquires lock and executes the function * 2. Subsequent requests wait for the result from cache * * @param key - Unique identifier for the operation (QueryKey or string) * @param fn - Async function to deduplicate * @returns Result of the function execution */ async dedupe(key, fn) { const lockAcquired = await this.tryAcquireLock(key); if (!lockAcquired) { return this.waitForResult(key, fn); } try { const result = await this.executeWithLock(key, fn); return result; } finally { await this.releaseLock(key); } } /** * Try to acquire a distributed lock via CacheApiAdaptor */ async tryAcquireLock(key) { if (!globalThis.caches) { return true; } try { const lockKey = this.buildLockKey(key); const existing = await this.lockCache.retrieve(lockKey); if (existing?.data) { return false; } const lockValue = { acquired: Date.now(), id: Math.random().toString(36).substring(7) }; await this.lockCache.update(lockKey, lockValue); const verification = await this.lockCache.retrieve(lockKey); if (verification?.data) { return verification.data.id === lockValue.id; } return false; } catch { return true; } } /** * Release the distributed lock */ async releaseLock(key) { if (!globalThis.caches) { return; } try { const lockKey = this.buildLockKey(key); await this.lockCache.delete(lockKey); } catch { } } /** * Execute function with lock held */ async executeWithLock(key, fn) { try { const result = await fn(); await this.cacheResult(key, result); return result; } catch (error) { await this.cacheResult(key, { __error: true, error }); throw error; } } /** * Wait for another worker/request to complete the operation */ async waitForResult(key, fn, attempt = 0) { const MAX_ATTEMPTS = 20; const RETRY_DELAY = 250; if (attempt >= MAX_ATTEMPTS) { return this.dedupe(key, fn); } const cachedResult = await this.getCachedResult(key); if (cachedResult !== null) { if (this.isErrorResult(cachedResult)) { throw cachedResult.error; } return cachedResult; } await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY)); return this.waitForResult(key, fn, attempt + 1); } /** * Store result in cache for other workers */ async cacheResult(key, result) { if (!globalThis.caches) { return; } try { const resultKey = this.buildResultKey(key); await this.resultCache.update(resultKey, result); } catch { } } /** * Get cached result from another worker */ async getCachedResult(key) { if (!globalThis.caches) { return null; } try { const resultKey = this.buildResultKey(key); const cached = await this.resultCache.retrieve(resultKey); return cached?.data ?? null; } catch { return null; } } /** * Check if a result represents an error */ isErrorResult(result) { return result && typeof result === "object" && result.__error === true; } /** * Build lock key for cache */ buildLockKey(key) { if (typeof key === "string") { return [ "dedupe-lock", key ]; } if (key instanceof URL) { const url = new URL(key); url.searchParams.set("_dedupe", "lock"); return url; } return [ "dedupe-lock", ...key ]; } /** * Build result key for cache */ buildResultKey(key) { if (typeof key === "string") { return [ "dedupe-result", key ]; } if (key instanceof URL) { const url = new URL(key); url.searchParams.set("_dedupe", "result"); return url; } return [ "dedupe-result", ...key ]; } }; // src/lib/create-query.ts var createQuery = /* @__PURE__ */ __name(async ({ queryKey, queryFn, gcTime, staleTime, revalidate, retry, retryDelay, cacheName, throwOnError, enabled = true, revalidateMode = "default" }) => { const dedupeManager = new DedupeManager(); try { if (!queryKey || !enabled || !gcTime) { const { data: data2, error: error2 } = await dedupeManager.dedupe(queryKey ?? (0, import_nanoid.nanoid)(), () => 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) { const refreshFunc = /* @__PURE__ */ __name(async () => { const refreshKey = cacheKey instanceof URL ? new URL(cacheKey.toString() + ":refresh") : [ ...cacheKey, "refresh" ]; await dedupeManager.dedupe(refreshKey, async () => { const newData = await queryFn(); await cache2.update(cacheKey, newData); return { data: newData, error: null }; }); }, "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 dedupeManager.dedupe(cacheKey, () => 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)(cache2.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"); // node_modules/hono/dist/http-exception.js var HTTPException = class extends Error { static { __name(this, "HTTPException"); } res; status; constructor(status = 500, options) { super(options?.message, { cause: options?.cause }); this.res = options?.res; this.status = status; } getResponse() { if (this.res) { const newResponse = new Response(this.res.body, { status: this.status, headers: this.res.headers }); return newResponse; } return new Response(this.message, { status: this.status }); } }; // src/lib/hono.ts var cache = /* @__PURE__ */ __name(({ cacheKey, handler, revalidate, ...options }) => async (ctx, next) => { const { data: response, error } = await createQuery({ ...options, queryKey: typeof cacheKey === "function" ? cacheKey(ctx) : cacheKey, queryFn: /* @__PURE__ */ __name(() => handler(ctx, next), "queryFn"), throwOnError: true, ...revalidate ? { revalidate: typeof revalidate === "boolean" ? revalidate : revalidate(ctx) } : {} }); if (!response || error) { throw new HTTPException(500); } return new Response(response.body, response); }, "cache"); // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { cache });