cache.ts (3.10 KB)
import { env } from "cloudflare:workers";
import { createLogger } from "./logger";

const logger = createLogger("Cache");

async function getJsonCache(): Promise<Cache> {
  const cache = await caches.open("gitflare:json");
  return cache;
}

type Params = Record<string, string | undefined>;

type BuildCacheKeyArgs = {
  key: string;
  params: Params;
};

function buildCacheKey({ key, params }: BuildCacheKeyArgs) {
  const path = key.startsWith("/") ? key : `/${key}`;
  const url = new URL(`/__cache${path}`, env.SITE_URL);
  // biome-ignore lint/suspicious/useGuardForIn: <idc>
  for (const param in params) {
    const value = params[param];
    if (value) url.searchParams.set(param, value);
  }
  return url;
}

type PutJsonArgs<T> = {
  /**
   * The base key for the cached item.
   * If the key does not start with `/`, it will also be added automatically.
   */
  key: string;
  /**
   * The data to be cached.
   */
  data: T;
  /**
   * Optional parameters to include in the cache key.
   */
  params?: Params;
  options?: {
    /**
     * Time to live in seconds for the cached item.
     * @default 1 year
     */
    ttlSeconds?: number;
  };
};

async function putJson<T>({ key, data, params, options }: PutJsonArgs<T>) {
  const ttl = options?.ttlSeconds ?? 60 * 60 * 24 * 365; // 1 year;
  const cache = await getJsonCache();
  const headers = new Headers({
    "Content-Type": "application/json",
    "Cache-Control": `public, max-age=${Math.floor(ttl)}`,
  });
  const response = new Response(JSON.stringify(data), { headers });
  const finalKey = buildCacheKey({
    key,
    params: params ?? {},
  });
  await cache.put(finalKey, response);
  logger.debug(`Cached data for key: ${finalKey.toString()}`);
}

type GetJsonArgs = {
  key: string;
  params?: Params;
};

async function getJson<T>({ key, params }: GetJsonArgs): Promise<T | null> {
  const cache = await getJsonCache();
  const finalKey = buildCacheKey({
    key,
    params: params ?? {},
  });
  const response = await cache.match(finalKey);
  if (!response || !response.ok) return null;
  const data = (await response.json()) as T;
  logger.debug("Cache hit for key: ", finalKey.toString());
  return data;
}

type GetOrSetJsonArgs<T> = {
  /**
   * The base key for the cached item.
   * If the key does not start with `/`, it will also be added automatically.
   */
  key: string;
  /**
   * Function to fetch the data if it's not in the cache.
   * Must resolve to the data to be cached.
   */
  fetcher: () => Promise<T> | T;
  /**
   * Optional parameters to include in the cache key.
   */
  params?: Params;
  options?: {
    /**
     * Time to live in seconds for the cached item.
     * @default 1 year
     */
    ttlSeconds?: number;
  };
};

async function getOrSetJson<T>({
  key,
  fetcher,
  options,
  params,
}: GetOrSetJsonArgs<T>): Promise<T> {
  const cached = await getJson<T>({ key, params });
  if (cached) return cached;

  const fresh = await fetcher();
  if (fresh === null || fresh === undefined) {
    return fresh;
  }
  await putJson({ key, data: fresh, params, options });
  return fresh;
}

export const cache = {
  putJson,
  getJson,
  getOrSetJson,
};