UNPKG

@sunney/flareutils

Version:

Small Utilities and little goodies that make developing with Cloudflare easier and faster.

366 lines 14.1 kB
/** * A Storage namespace that uses the Cloudflare Workers [KV API](https://developers.cloudflare.com/workers/runtime-apis/kv) to store data, with a [Cache API](https://developers.cloudflare.com/workers/runtime-apis/cache) backing that allows you to reduce your KV billable reads. * * For the most part, *BetterKV* should match to the Workers *KVNamespace* standard, other than how it is instantiated, and all methods(except delete) will be cached according to the configured `cacheTtl`. For the *KVNamespace* API, see the [types](https://github.com/cloudflare/workers-types) supplied by Cloudflare. * * @note This version of BetterKV supports KV v2. If you require support for KV v1, please import `BetterKVOld`. */ export class BetterKV { /** * Base URL used by BetterKV in Cache Operations. * @private */ URL = "https://better.kv/"; /** * Root KV instance utilized by BetterKV. * @private */ KV; /** * Utilized to ensure that any operations performed on the cache do not block the main thread. * @private */ waitUntil; /** * The name utilized to create a dedicated cache for this BetterKV instance. If you have multiple instances of BetterKV running in parallel, make sure each has their own unique cacheSpace. * @private */ config; /** * Cache instance utilized by BetterKV. * @private */ cache; /** * Creates a new BetterKV instance. * @param {KVNamespace} KV The KV Namespace to use as the primary data store. * @param {ExecutionContext["waitUntil"]} waitUntil The waitUntil function used to asyncronously update the cache. Must be passed in before executing any other methods on every new request. * @param {BetterKVConfig} config Configuration for the BetterKV instance. * @example ```ts * const NAMESPACE = new BetterKV(env.KV, ctx.waitUntil); * ``` */ constructor(KV, waitUntil, config) { this.KV = KV; this.waitUntil = waitUntil; this.config = { cacheSpace: undefined, probabilityGrowth: 1.28, cacheTtl: 50, kvCacheTtl: 3.15576e7, ...config, }; } /** * Retrieves the cache instance utilized by BetterKV. Ensures that the cache is only opened once, and can be shared across multiple runs of BetterKV. If no cacheSpace is provided, the default cache is used. * @private */ async getCache() { if (!this.cache) { if (this.config.cacheSpace) { this.cache = await caches.open(this.config.cacheSpace); } else { this.cache = caches.default; } } return this.cache; } /** * Used to update the waitUntil function to the ExecutionContext of the currently executing request. Should be passed in before executing any other methods on every new request. * @param {ExecutionContext["waitUntil"]} waitUntil The waitUntil function used to asyncronously update the cache. */ setWaitUntil(waitUntil) { this.waitUntil = waitUntil; } /** * Function to handle all GET-ops hitting origin KV. Should not be called manually. * @param {string} key The key to retrieve. * @private */ async getFromOrigin(key) { const baseHeaders = { "cloudflare-cdn-cache-control": `max-age=${this.config.cacheTtl}`, "betterkv-internal-created": Date.now().toString(), }; const { value, metadata } = await this.KV.getWithMetadata(key, { type: "stream", cacheTtl: this.config.kvCacheTtl, }); if (value === null) return null; return { res: new Response(value, { headers: { ...baseHeaders, "betterkv-internal-meta": metadata ? JSON.stringify(metadata) : "{}", "betterkv-internal-created": Date.now().toString(), }, }), meta: metadata, }; } async get(key, type) { const cache = await this.getCache(); const cacheKey = this.URL + key; const bodyVal = await cache.match(cacheKey); if (bodyVal) { const created = Number(bodyVal.headers.get("betterkv-internal-created")); const probability = isNaN(created) ? 1 : Math.pow(this.config.probabilityGrowth, Date.now() - created - this.config.cacheTtl); if (Math.random() < probability) { const a = async () => { const newResponse = await this.getFromOrigin(key); if (newResponse) { await cache.put(cacheKey, newResponse.res); } else { await cache.delete(cacheKey); } }; this.waitUntil(a()); } switch (type) { case "json": return (await bodyVal.json()); case "arrayBuffer": return await bodyVal.arrayBuffer(); case "stream": return bodyVal.body; case "text": default: return await bodyVal.text(); } } const originResponse = await this.getFromOrigin(key); if (!originResponse) return null; this.waitUntil(cache.put(cacheKey, originResponse.res.clone())); switch (type) { case "json": return originResponse.res.json(); case "arrayBuffer": return originResponse.res.arrayBuffer(); case "stream": return originResponse.res.body; case "text": default: return originResponse.res.text(); } } async getWithMetadata(key, type) { const cache = await this.getCache(); const cacheKey = this.URL + key; const bodyVal = await cache.match(cacheKey); if (bodyVal) { const created = Number(bodyVal.headers.get("betterkv-internal-created")); const probability = isNaN(created) ? 1 : Math.pow(this.config.probabilityGrowth, Date.now() - created - this.config.cacheTtl); let revalidated = false; if (Math.random() < probability) { revalidated = true; const a = async () => { const newResponse = await this.getFromOrigin(key); if (newResponse) { await cache.put(cacheKey, newResponse.res); } else { await cache.delete(cacheKey); } }; this.waitUntil(a()); } const rawMeta = bodyVal.headers.get("betterkv-internal-meta"); const metadata = rawMeta ? JSON.parse(rawMeta) : null; switch (type) { case "json": { return { value: await bodyVal.json(), metadata, cacheStatus: revalidated ? "REVALIDATED" : "HIT", }; } case "arrayBuffer": { return { value: await bodyVal.arrayBuffer(), metadata, cacheStatus: revalidated ? "REVALIDATED" : "HIT", }; } case "stream": { return { value: bodyVal.body, metadata, cacheStatus: revalidated ? "REVALIDATED" : "HIT", }; } case "text": default: { return { value: await bodyVal.text(), metadata, cacheStatus: revalidated ? "REVALIDATED" : "HIT", }; } } } const originResponse = await this.getFromOrigin(key); if (!originResponse) return null; this.waitUntil(cache.put(cacheKey, originResponse.res.clone())); switch (type) { case "json": { return { value: await originResponse.res.json(), metadata: originResponse.meta, cacheStatus: "MISS", }; } case "arrayBuffer": { return { value: await originResponse.res.arrayBuffer(), metadata: originResponse.meta, cacheStatus: "MISS", }; } case "stream": { return { value: originResponse.res.body, metadata: originResponse.meta, cacheStatus: "MISS", }; } case "text": default: { return { value: await originResponse.res.text(), metadata: originResponse.meta, cacheStatus: "MISS", }; } } } /** * Adds a new value to the BetterKV Namespace. Supports CacheTtl. * @param {string} key The key to add. * @param {BetterKVValueOptions} val The value to add. Type is inferred from the value. * @param {BetterKVAddOptions} options Options for the addition. * @example ```ts * await NAMESPACE.put(key, value); * ``` */ async put(key, val, options) { const cache = await this.getCache(); const cacheKey = this.URL + key; let cacheVal; let originVal; if (val instanceof ReadableStream) { const teed = val.tee(); cacheVal = teed[0]; originVal = teed[1]; } else { cacheVal = originVal = val; } this.waitUntil(cache.put(cacheKey, new Response(cacheVal, { headers: { "cloudflare-cdn-cache-control": `max-age=${this.config.cacheTtl}`, "betterkv-internal-created": Date.now().toString(), "betterkv-internal-meta": options?.metadata ? JSON.stringify(options.metadata) : "{}", }, }))); await this.KV.put(key, originVal, options); } /** * Removes a value from the BetterKV Namespace. * @param {string} key The key to remove. * @example ```ts * await NAMESPACE.delete(key); * ``` */ async delete(key) { const cache = await this.getCache(); this.waitUntil(cache.delete(this.URL + key)); await this.KV.delete(key); } /** * Lists keys in the BetterKV Namespace according to the options given. Supports CacheTtl. * @template M The type of the metadata. * @param {KVNamespaceListOptions} [opts] Options for the listing. * @returns {Promise<BetterKVListReturns<M>>} The keys in the namespace, and their associated metadata(if any). * @example ```ts * const {keys, list_complete, cursor} = await NAMESPACE.list(); * ``` */ async list(opts) { const cache = await this.getCache(); const cacheKey = new URL("https://list.better.kv"); let limit = 1000; let prefix = null; let cursor = null; if (opts) { if (opts.limit && opts.limit >= 1 && opts.limit < 1000) { limit = opts.limit; } if (opts.prefix) { prefix = opts.prefix; cacheKey.searchParams.set("prefix", prefix); } if (opts.limit) { limit = opts.limit; cacheKey.searchParams.set("limit", limit.toString()); } if (opts.cursor) { cursor = opts.cursor; cacheKey.searchParams.append("cursor", cursor); } } const bodyVal = await cache.match(cacheKey.toString()); if (bodyVal) { const created = Number(bodyVal.headers.get("betterkv-internal-created")); const probability = isNaN(created) ? 1 : Math.pow(this.config.probabilityGrowth, Date.now() - created - this.config.cacheTtl); let revalidated = false; if (Math.random() < probability) { revalidated = true; const a = async () => { const newResponse = await this.KV.list({ prefix, limit, cursor }); if (newResponse) { await cache.put(cacheKey, new Response(JSON.stringify(newResponse), { headers: { "cloudflare-cdn-cache-control": `max-age=${this.config.cacheTtl}`, }, })); } else { await cache.delete(cacheKey); } }; this.waitUntil(a()); } const res = (await bodyVal.json()); return { ...res, cacheStatus: revalidated ? "REVALIDATED" : "HIT", }; } const result = await this.KV.list({ prefix, limit, cursor }); this.waitUntil(cache.put(cacheKey.toString(), new Response(JSON.stringify(result), { headers: { "cloudflare-cdn-cache-control": `max-age=${this.config.cacheTtl}`, }, }))); return { ...result, cacheStatus: "MISS", }; } } export * from "./types"; export { BetterKVOld } from "./old"; //# sourceMappingURL=index.js.map