cf-workers-query
Version:
Automatically cache and revalidate data in Cloudflare Workers. Using the Cache API and Execution Context
457 lines (452 loc) • 14.2 kB
JavaScript
import {
__name
} from "./chunk-SHUYVCID.js";
// 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 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 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 cache = await getCache(this.cacheName);
const cacheKey = key instanceof URL ? key : this.buildCacheKey(key);
await cache.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
import { waitUntil } from "cloudflare:workers";
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 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) {
let alreadyRefreshing = false;
try {
alreadyRefreshing = await dedupeManager.isProcessing(cacheKey);
} catch {
}
if (!alreadyRefreshing) {
waitUntil((async () => {
await dedupeManager.markProcessing(cacheKey).catch(() => {
});
try {
const newData = await queryFn();
await cache.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 cache.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) {
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
};
waitUntil(pumpDone.then(async () => {
try {
if (pumpSuccess) {
const buffer = concatUint8Arrays(chunks, totalLength);
await cache.update(cacheKey, new Response(buffer, responseInit));
}
} finally {
await dedupeManager.clearProcessing(cacheKey).catch(() => {
});
}
}));
data = new Response(readable, responseInit);
} else {
data = await cache.update(cacheKey, data);
waitUntil(dedupeManager.clearProcessing(cacheKey).catch(() => {
}));
}
} else {
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");
export {
CacheApiAdaptor,
DedupeManager,
createQuery
};