@rb2bv/cache-handler
Version:
Next.js self-hosting simplified.
258 lines (254 loc) • 9.56 kB
JavaScript
// src/functions/nesh-cache.ts
import assert from "node:assert/strict";
import {
staticGenerationAsyncStorage
} from "next/dist/client/components/static-generation-async-storage.external.js";
// src/constants.ts
var MAX_INT32 = 2 ** 31 - 1;
var TIME_ONE_YEAR = 31536e3;
// src/functions/nesh-cache.ts
var NEXT_CACHE_IMPLICIT_TAG_ID = "_N_T_";
function getDerivedTags(pathname) {
const derivedTags = ["/layout"];
if (!pathname.startsWith("/")) {
return derivedTags;
}
const pathnameParts = pathname.split("/");
for (let i = 1; i < pathnameParts.length + 1; i++) {
let curPathname = pathnameParts.slice(0, i).join("/");
if (curPathname) {
if (!(curPathname.endsWith("/page") || curPathname.endsWith("/route"))) {
curPathname = `${curPathname}${curPathname.endsWith("/") ? "" : "/"}layout`;
}
derivedTags.push(curPathname);
}
}
return derivedTags;
}
function addImplicitTags(staticGenerationStore) {
const newTags = [];
const { pagePath, urlPathname } = staticGenerationStore;
if (!Array.isArray(staticGenerationStore.tags)) {
staticGenerationStore.tags = [];
}
if (pagePath) {
const derivedTags = getDerivedTags(pagePath);
for (let tag of derivedTags) {
tag = `${NEXT_CACHE_IMPLICIT_TAG_ID}${tag}`;
if (!staticGenerationStore.tags?.includes(tag)) {
staticGenerationStore.tags.push(tag);
}
newTags.push(tag);
}
}
if (urlPathname) {
const parsedPathname = new URL(urlPathname, "http://n").pathname;
const tag = `${NEXT_CACHE_IMPLICIT_TAG_ID}${parsedPathname}`;
if (!staticGenerationStore.tags?.includes(tag)) {
staticGenerationStore.tags.push(tag);
}
newTags.push(tag);
}
return newTags;
}
function serializeArguments(object) {
return JSON.stringify(object);
}
function serializeResult(object) {
return Buffer.from(JSON.stringify(object), "utf-8").toString("base64");
}
function deserializeResult(string) {
return JSON.parse(Buffer.from(string, "base64").toString("utf-8"));
}
function neshCache(callback, commonOptions) {
if (commonOptions?.resultSerializer && !commonOptions?.resultDeserializer) {
throw new Error("neshCache: if you provide a resultSerializer, you must provide a resultDeserializer.");
}
if (commonOptions?.resultDeserializer && !commonOptions?.resultSerializer) {
throw new Error("neshCache: if you provide a resultDeserializer, you must provide a resultSerializer.");
}
const commonTags = commonOptions?.tags ?? [];
const commonRevalidate = commonOptions?.revalidate ?? false;
const commonArgumentsSerializer = commonOptions?.argumentsSerializer ?? serializeArguments;
const commonResultSerializer = commonOptions?.resultSerializer ?? serializeResult;
const commonResultDeserializer = commonOptions?.resultDeserializer ?? deserializeResult;
async function cachedCallback(options, ...args) {
const store = staticGenerationAsyncStorage.getStore();
assert(store?.incrementalCache, "neshCache must be used in a Next.js app directory.");
const {
tags = [],
revalidate = commonRevalidate,
cacheKey,
argumentsSerializer = commonArgumentsSerializer,
resultDeserializer = commonResultDeserializer,
resultSerializer = commonResultSerializer
} = options ?? {};
assert(
revalidate === false || revalidate > 0 && Number.isInteger(revalidate),
"neshCache: revalidate must be a positive integer or false."
);
if (store.fetchCache === "force-no-store" || store.isDraftMode || store.incrementalCache.dev) {
return await callback(...args);
}
const uniqueTags = new Set(store.tags);
const combinedTags = [...tags, ...commonTags];
for (const tag of combinedTags) {
if (typeof tag === "string") {
uniqueTags.add(tag);
} else {
console.warn(`neshCache: Invalid tag: ${tag}. Skipping it. Expected a string.`);
}
}
const allTags = Array.from(uniqueTags);
store.tags = allTags;
store.revalidate = revalidate;
const fetchIdx = store.nextFetchId ?? 1;
store.nextFetchId = fetchIdx + 1;
const key = await store.incrementalCache.fetchCacheKey(`nesh-cache-${cacheKey ?? argumentsSerializer(args)}`);
const handleUnlock = await store.incrementalCache.lock(key);
let cacheData = null;
try {
cacheData = await store.incrementalCache.get(key, {
revalidate,
tags: allTags,
softTags: addImplicitTags(store),
kindHint: "fetch",
fetchIdx,
fetchUrl: "neshCache"
});
} catch (error) {
await handleUnlock();
throw error;
}
if (cacheData?.value?.kind === "FETCH" && cacheData.isStale === false) {
await handleUnlock();
return resultDeserializer(cacheData.value.data.body);
}
let data;
try {
data = await staticGenerationAsyncStorage.run(
{
...store,
// force any nested fetches to bypass cache so they revalidate
// when the unstable_cache call is revalidated
fetchCache: "force-no-store"
},
callback,
...args
);
} catch (error) {
throw error;
} finally {
await handleUnlock();
}
store.incrementalCache.set(
key,
{
kind: "FETCH",
data: {
body: resultSerializer(data),
headers: {},
url: "neshCache"
},
revalidate: revalidate || TIME_ONE_YEAR
},
{ revalidate, tags, fetchCache: true, fetchIdx, fetchUrl: "neshCache" }
);
return data;
}
return cachedCallback;
}
// src/functions/nesh-classic-cache.ts
import assert2 from "node:assert/strict";
import { createHash } from "node:crypto";
import { staticGenerationAsyncStorage as staticGenerationAsyncStorage2 } from "next/dist/client/components/static-generation-async-storage.external.js";
function hashCacheKey(url) {
const MAIN_KEY_PREFIX = "nesh-pages-cache-v1";
const cacheString = JSON.stringify([MAIN_KEY_PREFIX, url]);
return createHash("sha256").update(cacheString).digest("hex");
}
function serializeArguments2(object) {
return JSON.stringify(object);
}
function serializeResult2(object) {
return Buffer.from(JSON.stringify(object), "utf-8").toString("base64");
}
function deserializeResult2(string) {
return JSON.parse(Buffer.from(string, "base64").toString("utf-8"));
}
function neshClassicCache(callback, commonOptions) {
if (commonOptions?.resultSerializer && !commonOptions?.resultDeserializer) {
throw new Error("neshClassicCache: if you provide a resultSerializer, you must provide a resultDeserializer.");
}
if (commonOptions?.resultDeserializer && !commonOptions?.resultSerializer) {
throw new Error("neshClassicCache: if you provide a resultDeserializer, you must provide a resultSerializer.");
}
const commonRevalidate = commonOptions?.revalidate ?? false;
const commonArgumentsSerializer = commonOptions?.argumentsSerializer ?? serializeArguments2;
const commonResultSerializer = commonOptions?.resultSerializer ?? serializeResult2;
const commonResultDeserializer = commonOptions?.resultDeserializer ?? deserializeResult2;
async function cachedCallback(options, ...args) {
const store = staticGenerationAsyncStorage2.getStore();
assert2(!store?.incrementalCache, "neshClassicCache must be used in a Next.js Pages directory.");
const cacheHandler = globalThis?.__incrementalCache?.cacheHandler;
assert2(cacheHandler, "neshClassicCache must be used in a Next.js Pages directory.");
const {
responseContext,
tags = [],
revalidate = commonRevalidate,
cacheKey,
argumentsSerializer = commonArgumentsSerializer,
resultDeserializer = commonResultDeserializer,
resultSerializer = commonResultSerializer
} = options ?? {};
assert2(
revalidate === false || revalidate > 0 && Number.isInteger(revalidate),
"neshClassicCache: revalidate must be a positive integer or false."
);
responseContext?.setHeader("Cache-Control", `public, s-maxage=${revalidate}, stale-while-revalidate`);
const uniqueTags = /* @__PURE__ */ new Set();
for (const tag of tags) {
if (typeof tag === "string") {
uniqueTags.add(tag);
} else {
console.warn(`neshClassicCache: Invalid tag: ${tag}. Skipping it. Expected a string.`);
}
}
const allTags = Array.from(uniqueTags);
const key = hashCacheKey(`nesh-classic-cache-${cacheKey ?? argumentsSerializer(args)}`);
const cacheData = await cacheHandler.get(key, {
revalidate,
tags: allTags,
// @ts-expect-error
kindHint: "fetch",
fetchUrl: "neshClassicCache"
});
if (cacheData?.value?.kind === "FETCH" && cacheData.lifespan && cacheData.lifespan.staleAt > Date.now() / 1e3) {
return resultDeserializer(cacheData.value.data.body);
}
const data = await callback(...args);
cacheHandler.set(
key,
{
// @ts-expect-error
kind: "FETCH",
data: {
body: resultSerializer(data),
headers: {},
url: "neshClassicCache"
},
revalidate: revalidate || TIME_ONE_YEAR
},
{ revalidate, tags, fetchCache: true, fetchUrl: "neshClassicCache" }
);
if (cacheData?.value?.kind === "FETCH" && cacheData?.lifespan && cacheData.lifespan.expireAt > Date.now() / 1e3) {
return resultDeserializer(cacheData.value.data.body);
}
return data;
}
return cachedCallback;
}
export {
neshCache,
neshClassicCache
};