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
JavaScript
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
});