-
Notifications
You must be signed in to change notification settings - Fork 6
Support for neshClassicCache
#73
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
harry-gocity
wants to merge
5
commits into
fortedigital:canary
Choose a base branch
from
harry-gocity:neshClassicCache
base: canary
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
8edcc6f
chore: copy from neshca
harry-gocity 5f8b62e
refactor: use workAsyncStorage
harry-gocity a1a1460
style: format to match code style
harry-gocity 83575ae
build: update tsup + publish config
harry-gocity 508f83c
fix: imports
harry-gocity File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export { neshClassicCache } from "./nesh-classic-cache"; |
350 changes: 350 additions & 0 deletions
350
packages/nextjs-cache-handler/src/functions/nesh-classic-cache.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,350 @@ | ||
import assert from "node:assert/strict"; | ||
import { createHash } from "node:crypto"; | ||
import { Revalidate } from "../handlers/cache-handler.types"; | ||
import { workAsyncStorage } from "next/dist/server/app-render/work-async-storage.external.js"; | ||
import type { IncrementalCache } from "next/dist/server/lib/incremental-cache"; | ||
import { CacheHandler } from "../handlers/cache-handler"; | ||
import { CACHE_ONE_YEAR } from "next/dist/lib/constants"; | ||
import { CachedRouteKind } from "next/dist/server/response-cache"; | ||
|
||
declare global { | ||
var __incrementalCache: IncrementalCache | undefined; | ||
} | ||
|
||
function hashCacheKey(url: string): string { | ||
// this should be bumped anytime a fix is made to cache entries | ||
// that should bust the cache | ||
const MAIN_KEY_PREFIX = "nesh-pages-cache-v1"; | ||
|
||
const cacheString = JSON.stringify([MAIN_KEY_PREFIX, url]); | ||
|
||
return createHash("sha256").update(cacheString).digest("hex"); | ||
} | ||
|
||
/** | ||
* Serializes the given arguments into a string representation. | ||
* | ||
* @param object - The arguments to be serialized. | ||
* | ||
* @returns The serialized string representation of the arguments. | ||
*/ | ||
function serializeArguments(object: object): string { | ||
return JSON.stringify(object); | ||
} | ||
|
||
/** | ||
* Serializes the given object into a string representation. | ||
* | ||
* @param object - The object to be serialized. | ||
* | ||
* @returns The serialized string representation of the object. | ||
*/ | ||
function serializeResult(object: object): string { | ||
return Buffer.from(JSON.stringify(object), "utf-8").toString("base64"); | ||
} | ||
|
||
/** | ||
* Deserializes a string representation of an object into its original form. | ||
* | ||
* @param string - The string representation of the object. | ||
* | ||
* @returns The deserialized object. | ||
*/ | ||
function deserializeResult<T>(string: string): T { | ||
return JSON.parse(Buffer.from(string, "base64").toString("utf-8")); | ||
} | ||
|
||
/** | ||
* @template Arguments - The type of the arguments passed to the callback function. | ||
* | ||
* @template Result - The type of the value returned by the callback function. | ||
*/ | ||
type Callback<Arguments extends unknown[], Result> = ( | ||
...args: Arguments | ||
) => Result; | ||
|
||
/** | ||
* An object containing options for the cache. | ||
*/ | ||
type NeshClassicCacheOptions<Arguments extends unknown[], Result> = { | ||
/** | ||
* The response context object. | ||
* It is used to set the cache headers. | ||
*/ | ||
responseContext?: object & { | ||
setHeader( | ||
name: string, | ||
value: number | string | readonly string[], | ||
): unknown; | ||
}; | ||
/** | ||
* An array of tags to associate with the cached result. | ||
* Tags are used to revalidate the cache using the `revalidateTag` function. | ||
*/ | ||
tags?: string[]; | ||
/** | ||
* The revalidation interval in seconds. | ||
* Must be a positive integer or `false` to disable revalidation. | ||
* | ||
* @default revalidate // of the current route | ||
*/ | ||
revalidate?: Revalidate; | ||
/** | ||
* A custom cache key to be used instead of creating one from the arguments. | ||
*/ | ||
cacheKey?: string; | ||
/** | ||
* A function that serializes the arguments passed to the callback function. | ||
* Use it to create a cache key. | ||
* | ||
* @default (args) => JSON.stringify(args) | ||
* | ||
* @param callbackArguments - The arguments passed to the callback function. | ||
*/ | ||
argumentsSerializer?(callbackArguments: Arguments): string; | ||
/** | ||
* | ||
* A function that serializes the result of the callback function. | ||
* | ||
* @default (result) => Buffer.from(JSON.stringify(result)).toString('base64') | ||
* | ||
* @param result - The result of the callback function. | ||
*/ | ||
resultSerializer?(result: Result): string; | ||
/** | ||
* A function that deserializes the string representation of the result of the callback function. | ||
* | ||
* @default (string) => JSON.parse(Buffer.from(string, 'base64').toString('utf-8')) | ||
* | ||
* @param string - The string representation of the result of the callback function. | ||
*/ | ||
resultDeserializer?(string: string): Result; | ||
}; | ||
|
||
/** | ||
* An object containing common options for the cache. | ||
*/ | ||
type CommonNeshClassicCacheOptions<Arguments extends unknown[], Result> = Omit< | ||
NeshClassicCacheOptions<Arguments, Result>, | ||
"cacheKey" | "responseContext" | ||
>; | ||
|
||
/** | ||
* Experimental implementation of the "`unstable_cache`" for classic Next.js Pages Router. | ||
* It allows to cache data in the `getServerSideProps` and API routes. | ||
* | ||
* The API may change in the future. Use with caution. | ||
* | ||
* Caches the result of a callback function and returns a cached version if available. | ||
* If not available, it executes the callback function, caches the result, and returns it. | ||
* | ||
* @param callback - The callback function to be cached. | ||
* | ||
* @param options - An object containing options for the cache. | ||
* | ||
* @param options.responseContext - The response context object. | ||
* It is used to set the cache headers. | ||
* | ||
* @param options.tags - An array of tags to associate with the cached result. | ||
* Tags are used to revalidate the cache using the `revalidateTag` function. | ||
* | ||
* @param options.revalidate - The revalidation interval in seconds. | ||
* Must be a positive integer or `false` to disable revalidation. | ||
* Defaults to `export const revalidate = time;` in the current route. | ||
* | ||
* @param options.argumentsSerializer - A function that serializes the arguments passed to the callback function. | ||
* Use it to create a cache key. Defaults to `JSON.stringify(args)`. | ||
* | ||
* @param options.resultSerializer - A function that serializes the result of the callback function. | ||
* Defaults to `Buffer.from(JSON.stringify(data)).toString('base64')`. | ||
* | ||
* @param options.resultDeserializer - A function that deserializes the string representation of the result of the callback function. | ||
* Defaults to `JSON.parse(Buffer.from(data, 'base64').toString('utf-8'))`. | ||
* | ||
* @returns The callback wrapped in a caching function. | ||
* First argument is the cache options which can be used to override the common options. | ||
* In addition, there is a `cacheKey` option that can be used to provide a custom cache key. | ||
* | ||
* @throws If the `neshClassicCache` function is not used in a Next.js Pages directory or if the `revalidate` option is invalid. | ||
* | ||
* @example file: `src/pages/api/api-example.js` | ||
* | ||
* ```js | ||
* import { neshClassicCache } from '@neshca/cache-handler/functions'; | ||
* import axios from 'axios'; | ||
* | ||
* export const config = { | ||
* runtime: 'nodejs', | ||
* }; | ||
* | ||
* async function getViaAxios(url) { | ||
* try { | ||
* return (await axios.get(url.href)).data; | ||
* } catch (_error) { | ||
* return null; | ||
* } | ||
* } | ||
* | ||
* const cachedAxios = neshClassicCache(getViaAxios); | ||
* | ||
* export default async function handler(request, response) { | ||
* if (request.method !== 'GET') { | ||
* return response.status(405).send(null); | ||
* } | ||
* | ||
* const revalidate = 5; | ||
* | ||
* const url = new URL('https://api.example.com/data.json'); | ||
* | ||
* // Add tags to be able to revalidate the cache | ||
* const data = await cachedAxios({ revalidate, tags: [url.pathname], responseContext: response }, url); | ||
* | ||
* if (!data) { | ||
* response.status(404).send('Not found'); | ||
* | ||
* return; | ||
* } | ||
* | ||
* response.json(data); | ||
* } | ||
* ``` | ||
* | ||
* @remarks | ||
* - This function is intended to be used in a Next.js Pages directory. | ||
* | ||
* @since 1.8.0 | ||
*/ | ||
export function neshClassicCache< | ||
Arguments extends unknown[], | ||
Result extends Promise<unknown>, | ||
>( | ||
callback: Callback<Arguments, Result>, | ||
commonOptions?: CommonNeshClassicCacheOptions<Arguments, Result>, | ||
) { | ||
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 ?? serializeArguments; | ||
const commonResultSerializer = | ||
commonOptions?.resultSerializer ?? serializeResult; | ||
const commonResultDeserializer = | ||
commonOptions?.resultDeserializer ?? deserializeResult; | ||
|
||
async function cachedCallback( | ||
options: NeshClassicCacheOptions<Arguments, Result>, | ||
...args: Arguments | ||
): Promise<Result | null> { | ||
const store = workAsyncStorage.getStore(); | ||
|
||
assert( | ||
!store?.incrementalCache, | ||
"neshClassicCache must be used in a Next.js Pages directory.", | ||
); | ||
|
||
const cacheHandler = globalThis?.__incrementalCache?.cacheHandler as | ||
| InstanceType<typeof CacheHandler> | ||
| undefined; | ||
|
||
assert( | ||
cacheHandler, | ||
"neshClassicCache must be used in a Next.js Pages directory.", | ||
); | ||
|
||
const { | ||
responseContext, | ||
tags = [], | ||
revalidate = commonRevalidate, | ||
cacheKey, | ||
argumentsSerializer = commonArgumentsSerializer, | ||
resultDeserializer = commonResultDeserializer, | ||
resultSerializer = commonResultSerializer, | ||
} = options ?? {}; | ||
|
||
assert( | ||
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 = new Set<string>(); | ||
|
||
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, | ||
kind: "FETCH" as unknown as any, | ||
fetchUrl: "neshClassicCache", | ||
}); | ||
|
||
if ( | ||
cacheData?.value?.kind === "FETCH" && | ||
cacheData.lifespan && | ||
cacheData.lifespan.staleAt > Date.now() / 1000 | ||
) { | ||
return resultDeserializer(cacheData.value.data.body); | ||
} | ||
|
||
const data: Result = await callback(...args); | ||
|
||
cacheHandler.set( | ||
key, | ||
{ | ||
kind: "FETCH" as CachedRouteKind.FETCH, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Similar thoughts here re casting. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I know that pain, unfortunately I did not find a nice way of solving it. |
||
data: { | ||
body: resultSerializer(data), | ||
headers: {}, | ||
url: "neshClassicCache", | ||
}, | ||
revalidate: revalidate || CACHE_ONE_YEAR, | ||
}, | ||
{ | ||
revalidate, | ||
tags, | ||
fetchCache: true, | ||
fetchUrl: "neshClassicCache", | ||
}, | ||
); | ||
|
||
if ( | ||
cacheData?.value?.kind === "FETCH" && | ||
cacheData?.lifespan && | ||
cacheData.lifespan.expireAt > Date.now() / 1000 | ||
) { | ||
return resultDeserializer(cacheData.value.data.body); | ||
} | ||
|
||
return data; | ||
} | ||
|
||
return cachedCallback; | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't love this casting, but importing
CachedRouteKind
fromnext/dist
raisesThis also was
kindHint: "fetch"
. But that seems incompatible with theCacheHandler
typings. I haven't been able to find any reference to whatkindHint
actually does.https://github.com/search?q=repo%3Acaching-tools%2Fnext-shared-cache%20kindHint
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes I am familiar with this issue of casting and importing enum from next/dist. I think we need to just accept it as it is. Perhaps not to
any
, but I will take a look more closely later this week or on Monday.