UNPKG

@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
// 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 ?? []; }