@cloudflare/next-on-pages
Version:
`@cloudflare/next-on-pages` is a CLI tool that you can use to build and develop [Next.js](https://nextjs.org/) applications so that they can run on the [Cloudflare Pages](https://pages.cloudflare.com/) platform (and integrate with Cloudflare's various oth
320 lines (272 loc) • 8.96 kB
text/typescript
// NOTE: This is given the same name that the environment variable has in the Next.js source code.
export const SUSPENSE_CACHE_URL = 'INTERNAL_SUSPENSE_CACHE_HOSTNAME.local';
// https://github.com/vercel/next.js/blob/f6babb4/packages/next/src/lib/constants.ts#23
const NEXT_CACHE_IMPLICIT_TAG_ID = '_N_T_';
// Set to track the revalidated tags in requests.
const revalidatedTags = new Set<string>();
/** Generic adaptor for the Suspense Cache. */
export class CacheAdaptor {
/** The tags manifest for fetch calls. */
public tagsManifest: TagsManifest | undefined;
/** The key used for the tags manifest in the cache. */
public tagsManifestKey = 'tags-manifest';
/** Promise that resolves when tags manifest is loaded */
public tagsManifestPromise: Promise<void> | undefined;
/**
* @param ctx The incremental cache context from Next.js. NOTE: This is not currently utilised in NOP.
*/
constructor(protected ctx: Record<string, unknown> = {}) {}
/**
* Retrieves an entry from the storage mechanism.
*
* @param key Key for the item.
* @returns The value, or null if no entry exists.
*/
public async retrieve(key: string): Promise<string | null> {
throw new Error(`Method not implemented - ${key}`);
}
/**
* Updates an entry in the storage mechanism.
*
* @param key Key for the item.
* @param value The value to update.
*/
public async update(
key: string,
value: string,
revalidate?: number,
): Promise<void> {
throw new Error(`Method not implemented - ${key}, ${value}, ${revalidate}`);
}
/**
* Puts a new entry in the suspense cache.
*
* @param key Key for the item in the suspense cache.
* @param value The cached value to add to the suspense cache.
*/
public async set(key: string, value: IncrementalCacheValue): Promise<void> {
const newEntry: CacheHandlerValue = {
lastModified: Date.now(),
value,
};
// Update the cache entry.
const updateOp = this.update(
key,
JSON.stringify(newEntry),
value.revalidate,
);
switch (newEntry.value?.kind) {
case 'FETCH': {
// Update the tags with the cache key.
const tags = getTagsFromEntry(newEntry);
await this.setTags(tags, { cacheKey: key });
const derivedTags = getDerivedTags(tags);
const implicitTags = derivedTags.map(
tag => `${NEXT_CACHE_IMPLICIT_TAG_ID}${tag}`,
);
[...derivedTags, ...implicitTags].forEach(tag =>
revalidatedTags.delete(tag),
);
}
}
// Make sure the cache has been updated before returning
await updateOp;
}
/**
* Retrieves an entry from the suspense cache.
*
* @param key Key for the item in the suspense cache.
* @param opts Soft cache tags used when checking if an entry is stale.
* @returns The cached value, or null if no entry exists.
*/
public async get(
key: string,
{ softTags }: { softTags?: string[] },
): Promise<CacheHandlerValue | null> {
// Get entry from the cache.
const entryPromise = this.retrieve(key);
// Start loading the tags manifest.
const tagsManifestLoad = this.loadTagsManifest();
const entry = await entryPromise;
if (!entry) return null;
let data: CacheHandlerValue;
try {
data = JSON.parse(entry) as CacheHandlerValue;
} catch (e) {
// Failed to parse the cache entry, so it's invalid.
return null;
}
switch (data.value?.kind) {
case 'FETCH': {
// Await for the tags manifest to end loading.
await tagsManifestLoad;
// Check if the cache entry is stale or fresh based on the tags.
const tags = getTagsFromEntry(data);
const combinedTags = softTags
? [...tags, ...softTags]
: getDerivedTags(tags);
const isStale = combinedTags.some(tag => {
// If a revalidation has been triggered, the current entry is stale.
if (revalidatedTags.has(tag)) return true;
const tagEntry = this.tagsManifest?.items?.[tag];
return (
tagEntry?.revalidatedAt &&
tagEntry?.revalidatedAt >= (data.lastModified ?? Date.now())
);
});
// Don't return stale data from the cache.
return isStale ? null : data;
}
default: {
return data;
}
}
}
/**
* Revalidates a tag in the suspense cache's tags manifest.
*
* @param tag Tag to revalidate.
*/
public async revalidateTag(tag: string): Promise<void> {
// Update the revalidated timestamp for the tags in the tags manifest.
await this.setTags([tag], { revalidatedAt: Date.now() });
revalidatedTags.add(tag);
}
/**
* Loads the tags manifest from the suspense cache.
*
* @param force Whether to force a reload of the tags manifest.
*/
public async loadTagsManifest(force = false): Promise<void> {
// Load tags manifest if missing or refresh if forced.
const shouldLoad = force || !this.tagsManifest;
if (!shouldLoad) {
return;
}
// If the tags manifest is not already being loaded, kickstart the retrieval.
if (!this.tagsManifestPromise) {
this.tagsManifestPromise = this.loadTagsManifestInternal();
}
await this.tagsManifestPromise;
}
/**
* Internal method to load the tags manifest from the suspense cache.
*/
private async loadTagsManifestInternal(): Promise<void> {
try {
const rawManifest = await this.retrieve(this.tagsManifestKey);
if (rawManifest) {
this.tagsManifest = JSON.parse(rawManifest) as TagsManifest;
}
} catch (e) {
// noop
}
this.tagsManifest ??= { version: 1, items: {} };
this.tagsManifestPromise = undefined;
}
/**
* Saves the local tags manifest in the suspence cache.
*/
public async saveTagsManifest(): Promise<void> {
if (this.tagsManifest) {
const newValue = JSON.stringify(this.tagsManifest);
await this.update(this.tagsManifestKey, newValue);
}
}
/**
* Sets the tags for an item in the suspense cache's tags manifest.
*
* @param tags Tags for the key.
* @param setTagsInfo Key for the item in the suspense cache, or the new revalidated at timestamp.
*/
public async setTags(
tags: string[],
{ cacheKey, revalidatedAt }: { cacheKey?: string; revalidatedAt?: number },
): Promise<void> {
await this.loadTagsManifest(true);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const tagsManifest = this.tagsManifest!;
for (const tag of tags) {
const data = tagsManifest.items[tag] ?? { keys: [] };
if (cacheKey && !data.keys.includes(cacheKey)) {
data.keys.push(cacheKey);
}
if (revalidatedAt) {
data.revalidatedAt = revalidatedAt;
}
tagsManifest.items[tag] = data;
}
await this.saveTagsManifest();
}
/**
* Builds the full cache key for the suspense cache.
*
* @param key Key for the item in the suspense cache.
* @returns The fully-formed cache key for the suspense cache.
*/
public buildCacheKey(key: string) {
return `https://${SUSPENSE_CACHE_URL}/entry/${key}`;
}
}
// https://github.com/vercel/next.js/blob/261db49/packages/next/src/server/lib/incremental-cache/file-system-cache.ts#L17
export type TagsManifest = {
version: 1;
items: { [tag: string]: TagsManifestItem };
};
export type TagsManifestItem = { keys: string[]; revalidatedAt?: number };
// https://github.com/vercel/next.js/blob/df4c2aa8/packages/next/src/server/response-cache/types.ts#L24
export type CachedFetchValue = {
kind: 'FETCH';
data: {
headers: { [k: string]: string };
body: string;
url: string;
status?: number;
// field used by older versions of Next.js (see: https://github.com/vercel/next.js/blob/fda1ecc/packages/next/src/server/response-cache/types.ts#L23)
tags?: string[];
};
// tags are only present with file-system-cache
// fetch cache stores tags outside of cache entry
tags?: string[];
revalidate: number;
};
export type CacheHandlerValue = {
lastModified?: number;
age?: number;
cacheState?: string;
value: IncrementalCacheValue | null;
};
export type IncrementalCacheValue = CachedFetchValue;
/**
* Derives a list of tags from the given tags. This is taken from the Next.js source code.
*
* @see https://github.com/vercel/next.js/blob/1286e145/packages/next/src/server/lib/incremental-cache/utils.ts
*
* @param tags Array of tags.
* @returns Derived tags.
*/
export function getDerivedTags(tags: string[]): string[] {
const derivedTags: string[] = ['/'];
for (const tag of tags || []) {
if (tag.startsWith('/')) {
const pathnameParts = tag.split('/');
// we automatically add the current path segments as tags
// for revalidatePath handling
for (let i = 1; i < pathnameParts.length + 1; i++) {
const curPathname = pathnameParts.slice(0, i).join('/');
if (curPathname) {
derivedTags.push(curPathname);
if (!derivedTags.includes(curPathname)) {
derivedTags.push(curPathname);
}
}
}
} else if (!derivedTags.includes(tag)) {
derivedTags.push(tag);
}
}
return derivedTags;
}
export function getTagsFromEntry(entry: CacheHandlerValue): string[] {
return entry.value?.tags ?? entry.value?.data?.tags ?? [];
}