Skip to content

Commit 5d86d6b

Browse files
authored
Support for neshClassicCache (#73)
* chore: copy from neshca * refactor: use workAsyncStorage * style: format to match code style * build: update tsup + publish config * fix: imports
1 parent 257b0d4 commit 5d86d6b

File tree

4 files changed

+359
-0
lines changed

4 files changed

+359
-0
lines changed

packages/nextjs-cache-handler/package.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@
3131
"require": "./dist/handlers/*.cjs",
3232
"import": "./dist/handlers/*.js"
3333
},
34+
"./functions": {
35+
"require": "./dist/functions/functions.cjs",
36+
"import": "./dist/functions/functions.js"
37+
},
3438
"./instrumentation": {
3539
"require": "./dist/instrumentation/instrumentation.cjs",
3640
"import": "./dist/instrumentation/instrumentation.js"
@@ -53,6 +57,9 @@
5357
"*": [
5458
"dist/handlers/cache-handler.d.ts"
5559
],
60+
"functions": [
61+
"dist/functions/functions.d.ts"
62+
],
5663
"instrumentation": [
5764
"dist/instrumentation/instrumentation.d.ts"
5865
],
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { neshClassicCache } from "./nesh-classic-cache";
Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,350 @@
1+
import assert from "node:assert/strict";
2+
import { createHash } from "node:crypto";
3+
import { Revalidate } from "../handlers/cache-handler.types";
4+
import { workAsyncStorage } from "next/dist/server/app-render/work-async-storage.external.js";
5+
import type { IncrementalCache } from "next/dist/server/lib/incremental-cache";
6+
import { CacheHandler } from "../handlers/cache-handler";
7+
import { CACHE_ONE_YEAR } from "next/dist/lib/constants";
8+
import { CachedRouteKind } from "next/dist/server/response-cache";
9+
10+
declare global {
11+
var __incrementalCache: IncrementalCache | undefined;
12+
}
13+
14+
function hashCacheKey(url: string): string {
15+
// this should be bumped anytime a fix is made to cache entries
16+
// that should bust the cache
17+
const MAIN_KEY_PREFIX = "nesh-pages-cache-v1";
18+
19+
const cacheString = JSON.stringify([MAIN_KEY_PREFIX, url]);
20+
21+
return createHash("sha256").update(cacheString).digest("hex");
22+
}
23+
24+
/**
25+
* Serializes the given arguments into a string representation.
26+
*
27+
* @param object - The arguments to be serialized.
28+
*
29+
* @returns The serialized string representation of the arguments.
30+
*/
31+
function serializeArguments(object: object): string {
32+
return JSON.stringify(object);
33+
}
34+
35+
/**
36+
* Serializes the given object into a string representation.
37+
*
38+
* @param object - The object to be serialized.
39+
*
40+
* @returns The serialized string representation of the object.
41+
*/
42+
function serializeResult(object: object): string {
43+
return Buffer.from(JSON.stringify(object), "utf-8").toString("base64");
44+
}
45+
46+
/**
47+
* Deserializes a string representation of an object into its original form.
48+
*
49+
* @param string - The string representation of the object.
50+
*
51+
* @returns The deserialized object.
52+
*/
53+
function deserializeResult<T>(string: string): T {
54+
return JSON.parse(Buffer.from(string, "base64").toString("utf-8"));
55+
}
56+
57+
/**
58+
* @template Arguments - The type of the arguments passed to the callback function.
59+
*
60+
* @template Result - The type of the value returned by the callback function.
61+
*/
62+
type Callback<Arguments extends unknown[], Result> = (
63+
...args: Arguments
64+
) => Result;
65+
66+
/**
67+
* An object containing options for the cache.
68+
*/
69+
type NeshClassicCacheOptions<Arguments extends unknown[], Result> = {
70+
/**
71+
* The response context object.
72+
* It is used to set the cache headers.
73+
*/
74+
responseContext?: object & {
75+
setHeader(
76+
name: string,
77+
value: number | string | readonly string[],
78+
): unknown;
79+
};
80+
/**
81+
* An array of tags to associate with the cached result.
82+
* Tags are used to revalidate the cache using the `revalidateTag` function.
83+
*/
84+
tags?: string[];
85+
/**
86+
* The revalidation interval in seconds.
87+
* Must be a positive integer or `false` to disable revalidation.
88+
*
89+
* @default revalidate // of the current route
90+
*/
91+
revalidate?: Revalidate;
92+
/**
93+
* A custom cache key to be used instead of creating one from the arguments.
94+
*/
95+
cacheKey?: string;
96+
/**
97+
* A function that serializes the arguments passed to the callback function.
98+
* Use it to create a cache key.
99+
*
100+
* @default (args) => JSON.stringify(args)
101+
*
102+
* @param callbackArguments - The arguments passed to the callback function.
103+
*/
104+
argumentsSerializer?(callbackArguments: Arguments): string;
105+
/**
106+
*
107+
* A function that serializes the result of the callback function.
108+
*
109+
* @default (result) => Buffer.from(JSON.stringify(result)).toString('base64')
110+
*
111+
* @param result - The result of the callback function.
112+
*/
113+
resultSerializer?(result: Result): string;
114+
/**
115+
* A function that deserializes the string representation of the result of the callback function.
116+
*
117+
* @default (string) => JSON.parse(Buffer.from(string, 'base64').toString('utf-8'))
118+
*
119+
* @param string - The string representation of the result of the callback function.
120+
*/
121+
resultDeserializer?(string: string): Result;
122+
};
123+
124+
/**
125+
* An object containing common options for the cache.
126+
*/
127+
type CommonNeshClassicCacheOptions<Arguments extends unknown[], Result> = Omit<
128+
NeshClassicCacheOptions<Arguments, Result>,
129+
"cacheKey" | "responseContext"
130+
>;
131+
132+
/**
133+
* Experimental implementation of the "`unstable_cache`" for classic Next.js Pages Router.
134+
* It allows to cache data in the `getServerSideProps` and API routes.
135+
*
136+
* The API may change in the future. Use with caution.
137+
*
138+
* Caches the result of a callback function and returns a cached version if available.
139+
* If not available, it executes the callback function, caches the result, and returns it.
140+
*
141+
* @param callback - The callback function to be cached.
142+
*
143+
* @param options - An object containing options for the cache.
144+
*
145+
* @param options.responseContext - The response context object.
146+
* It is used to set the cache headers.
147+
*
148+
* @param options.tags - An array of tags to associate with the cached result.
149+
* Tags are used to revalidate the cache using the `revalidateTag` function.
150+
*
151+
* @param options.revalidate - The revalidation interval in seconds.
152+
* Must be a positive integer or `false` to disable revalidation.
153+
* Defaults to `export const revalidate = time;` in the current route.
154+
*
155+
* @param options.argumentsSerializer - A function that serializes the arguments passed to the callback function.
156+
* Use it to create a cache key. Defaults to `JSON.stringify(args)`.
157+
*
158+
* @param options.resultSerializer - A function that serializes the result of the callback function.
159+
* Defaults to `Buffer.from(JSON.stringify(data)).toString('base64')`.
160+
*
161+
* @param options.resultDeserializer - A function that deserializes the string representation of the result of the callback function.
162+
* Defaults to `JSON.parse(Buffer.from(data, 'base64').toString('utf-8'))`.
163+
*
164+
* @returns The callback wrapped in a caching function.
165+
* First argument is the cache options which can be used to override the common options.
166+
* In addition, there is a `cacheKey` option that can be used to provide a custom cache key.
167+
*
168+
* @throws If the `neshClassicCache` function is not used in a Next.js Pages directory or if the `revalidate` option is invalid.
169+
*
170+
* @example file: `src/pages/api/api-example.js`
171+
*
172+
* ```js
173+
* import { neshClassicCache } from '@neshca/cache-handler/functions';
174+
* import axios from 'axios';
175+
*
176+
* export const config = {
177+
* runtime: 'nodejs',
178+
* };
179+
*
180+
* async function getViaAxios(url) {
181+
* try {
182+
* return (await axios.get(url.href)).data;
183+
* } catch (_error) {
184+
* return null;
185+
* }
186+
* }
187+
*
188+
* const cachedAxios = neshClassicCache(getViaAxios);
189+
*
190+
* export default async function handler(request, response) {
191+
* if (request.method !== 'GET') {
192+
* return response.status(405).send(null);
193+
* }
194+
*
195+
* const revalidate = 5;
196+
*
197+
* const url = new URL('https://api.example.com/data.json');
198+
*
199+
* // Add tags to be able to revalidate the cache
200+
* const data = await cachedAxios({ revalidate, tags: [url.pathname], responseContext: response }, url);
201+
*
202+
* if (!data) {
203+
* response.status(404).send('Not found');
204+
*
205+
* return;
206+
* }
207+
*
208+
* response.json(data);
209+
* }
210+
* ```
211+
*
212+
* @remarks
213+
* - This function is intended to be used in a Next.js Pages directory.
214+
*
215+
* @since 1.8.0
216+
*/
217+
export function neshClassicCache<
218+
Arguments extends unknown[],
219+
Result extends Promise<unknown>,
220+
>(
221+
callback: Callback<Arguments, Result>,
222+
commonOptions?: CommonNeshClassicCacheOptions<Arguments, Result>,
223+
) {
224+
if (commonOptions?.resultSerializer && !commonOptions?.resultDeserializer) {
225+
throw new Error(
226+
"neshClassicCache: if you provide a resultSerializer, you must provide a resultDeserializer.",
227+
);
228+
}
229+
230+
if (commonOptions?.resultDeserializer && !commonOptions?.resultSerializer) {
231+
throw new Error(
232+
"neshClassicCache: if you provide a resultDeserializer, you must provide a resultSerializer.",
233+
);
234+
}
235+
236+
const commonRevalidate = commonOptions?.revalidate ?? false;
237+
const commonArgumentsSerializer =
238+
commonOptions?.argumentsSerializer ?? serializeArguments;
239+
const commonResultSerializer =
240+
commonOptions?.resultSerializer ?? serializeResult;
241+
const commonResultDeserializer =
242+
commonOptions?.resultDeserializer ?? deserializeResult;
243+
244+
async function cachedCallback(
245+
options: NeshClassicCacheOptions<Arguments, Result>,
246+
...args: Arguments
247+
): Promise<Result | null> {
248+
const store = workAsyncStorage.getStore();
249+
250+
assert(
251+
!store?.incrementalCache,
252+
"neshClassicCache must be used in a Next.js Pages directory.",
253+
);
254+
255+
const cacheHandler = globalThis?.__incrementalCache?.cacheHandler as
256+
| InstanceType<typeof CacheHandler>
257+
| undefined;
258+
259+
assert(
260+
cacheHandler,
261+
"neshClassicCache must be used in a Next.js Pages directory.",
262+
);
263+
264+
const {
265+
responseContext,
266+
tags = [],
267+
revalidate = commonRevalidate,
268+
cacheKey,
269+
argumentsSerializer = commonArgumentsSerializer,
270+
resultDeserializer = commonResultDeserializer,
271+
resultSerializer = commonResultSerializer,
272+
} = options ?? {};
273+
274+
assert(
275+
revalidate === false || (revalidate > 0 && Number.isInteger(revalidate)),
276+
"neshClassicCache: revalidate must be a positive integer or false.",
277+
);
278+
279+
responseContext?.setHeader(
280+
"Cache-Control",
281+
`public, s-maxage=${revalidate}, stale-while-revalidate`,
282+
);
283+
284+
const uniqueTags = new Set<string>();
285+
286+
for (const tag of tags) {
287+
if (typeof tag === "string") {
288+
uniqueTags.add(tag);
289+
} else {
290+
console.warn(
291+
`neshClassicCache: Invalid tag: ${tag}. Skipping it. Expected a string.`,
292+
);
293+
}
294+
}
295+
296+
const allTags = Array.from(uniqueTags);
297+
298+
const key = hashCacheKey(
299+
`nesh-classic-cache-${cacheKey ?? argumentsSerializer(args)}`,
300+
);
301+
302+
const cacheData = await cacheHandler.get(key, {
303+
revalidate,
304+
tags: allTags,
305+
kind: "FETCH" as unknown as any,
306+
fetchUrl: "neshClassicCache",
307+
});
308+
309+
if (
310+
cacheData?.value?.kind === "FETCH" &&
311+
cacheData.lifespan &&
312+
cacheData.lifespan.staleAt > Date.now() / 1000
313+
) {
314+
return resultDeserializer(cacheData.value.data.body);
315+
}
316+
317+
const data: Result = await callback(...args);
318+
319+
cacheHandler.set(
320+
key,
321+
{
322+
kind: "FETCH" as CachedRouteKind.FETCH,
323+
data: {
324+
body: resultSerializer(data),
325+
headers: {},
326+
url: "neshClassicCache",
327+
},
328+
revalidate: revalidate || CACHE_ONE_YEAR,
329+
},
330+
{
331+
revalidate,
332+
tags,
333+
fetchCache: true,
334+
fetchUrl: "neshClassicCache",
335+
},
336+
);
337+
338+
if (
339+
cacheData?.value?.kind === "FETCH" &&
340+
cacheData?.lifespan &&
341+
cacheData.lifespan.expireAt > Date.now() / 1000
342+
) {
343+
return resultDeserializer(cacheData.value.data.body);
344+
}
345+
346+
return data;
347+
}
348+
349+
return cachedCallback;
350+
}

packages/nextjs-cache-handler/tsup.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export const tsup = defineConfig({
55
entry: [
66
"src/handlers/*.ts",
77
"src/instrumentation/*.ts",
8+
"src/functions/*.ts",
89
"src/helpers/redisClusterAdapter.ts",
910
"src/helpers/withAbortSignal.ts",
1011
"src/helpers/withAbortSignalProxy.ts",

0 commit comments

Comments
 (0)