From 291bfdef9f303a05a3dfe9eaa0e3ef00fd05ed8d Mon Sep 17 00:00:00 2001 From: James Date: Mon, 21 Aug 2023 12:29:28 +0100 Subject: [PATCH] feat: fetch (suspense) cache handling, and `next/cache` support (#419) * feat: fetch (suspense) cache handling, and `next/cache` support * fix tag revalidation + stale entry revalidation * D1 and R2 cache interfaces * docs * Apply suggestions from code review Co-authored-by: Dario Piotrowicz * commit desktop changes so i can move to my laptop * done i think? * docs * comment * *smashes keyboard against head* * Update packages/next-on-pages/templates/cache/interface.ts Co-authored-by: Dario Piotrowicz * add `.local` to hostname * apply suggestions + tweaks * Apply suggestions from code review Co-authored-by: Dario Piotrowicz * dario broke prettier --------- Co-authored-by: Dario Piotrowicz --- .changeset/few-icons-shop.md | 5 + .changeset/forty-seas-hug.md | 5 + packages/next-on-pages/docs/caching.md | 15 ++ packages/next-on-pages/docs/supported.md | 12 +- .../dedupeEdgeFunctions.ts | 8 + .../templates/_worker.js/index.ts | 5 +- .../templates/_worker.js/utils/cache.ts | 76 ++++++ .../templates/_worker.js/utils/fetch.ts | 11 +- .../next-on-pages/templates/cache/adaptor.ts | 243 ++++++++++++++++++ .../templates/cache/cache-api.ts | 42 +++ .../next-on-pages/templates/cache/index.ts | 1 + 11 files changed, 407 insertions(+), 16 deletions(-) create mode 100644 .changeset/few-icons-shop.md create mode 100644 .changeset/forty-seas-hug.md create mode 100644 packages/next-on-pages/docs/caching.md create mode 100644 packages/next-on-pages/templates/_worker.js/utils/cache.ts create mode 100644 packages/next-on-pages/templates/cache/adaptor.ts create mode 100644 packages/next-on-pages/templates/cache/cache-api.ts create mode 100644 packages/next-on-pages/templates/cache/index.ts diff --git a/.changeset/few-icons-shop.md b/.changeset/few-icons-shop.md new file mode 100644 index 000000000..2efd34828 --- /dev/null +++ b/.changeset/few-icons-shop.md @@ -0,0 +1,5 @@ +--- +'@cloudflare/next-on-pages': patch +--- + +Stop the `cache` property in fetch requests causing internal server error. diff --git a/.changeset/forty-seas-hug.md b/.changeset/forty-seas-hug.md new file mode 100644 index 000000000..91155ebfd --- /dev/null +++ b/.changeset/forty-seas-hug.md @@ -0,0 +1,5 @@ +--- +'@cloudflare/next-on-pages': minor +--- + +Support for the internal fetch (suspense) cache, and `next/cache` data revalidation. diff --git a/packages/next-on-pages/docs/caching.md b/packages/next-on-pages/docs/caching.md new file mode 100644 index 000000000..4426be304 --- /dev/null +++ b/packages/next-on-pages/docs/caching.md @@ -0,0 +1,15 @@ +# Caching and Data Revalidation + +`@cloudflare/next-on-pages` comes with support for data revalidation and caching for fetch requests. This is done in our router and acts as an extension to Next.js' built-in functionality. + +## Storage Options + +There are various different bindings and storage options that one could use for caching. At the moment, `@cloudflare/next-on-pages` supports the Cache API out-of-the-box. + +In the future, support will be available for creating custom cache interfaces and using different bindings. + +### Cache API + +The [Cache API](https://developers.cloudflare.com/workers/runtime-apis/cache/) is a per data-center cache that is ideal for storing data that is not required to be accessible globally. It is worth noting that Vercel's Data Cache is regional, like with the Cache API, so there is no difference in terms of data availability. + +Due to how the Cache API works, you need to be using a domain for your deployment for it to take effect. diff --git a/packages/next-on-pages/docs/supported.md b/packages/next-on-pages/docs/supported.md index cd39b4d79..7ec2d714b 100644 --- a/packages/next-on-pages/docs/supported.md +++ b/packages/next-on-pages/docs/supported.md @@ -184,14 +184,4 @@ export async function getStaticPaths() { #### Revalidating Data and `next/cache` -Revalidation and `next/cache` are not supported on Cloudflare Pages. This is used by the default `fetch` cache, which forms part of the incremental cache for revalidating data inside the App Router. Revalidating tags and data for an entire path also uses `next/cache`. - -The Next.js cache does however work when self-hosting by optionally providing a [custom cache handler](https://nextjs.org/docs/app/api-reference/next-config-js/incrementalCacheHandlerPath). It's possible this could use Cloudflare KV or Durable Objects in the future. - -##### Fetch Cache - -Cloudflare Pages' runtime does not support the `cache` property on the [patched fetch](https://github.com/vercel/next.js/blob/canary/packages/next/src/server/lib/patch-fetch.ts) used in Next.js. For example, the following piece of code would throw an error when run on Cloudflare Pages. This is due to the fact that the `cache` property is not supported by the [Fetch API](https://developers.cloudflare.com/workers/runtime-apis/request/#requestinit) implemented in the Workers runtime. - -```typescript -fetch('https://...', { cache: 'no-store' }); -``` +Revalidation and `next/cache` are supported on Cloudflare Pages, and can use various bindings. For more information, see our [caching documentation](./caching). diff --git a/packages/next-on-pages/src/buildApplication/processVercelFunctions/dedupeEdgeFunctions.ts b/packages/next-on-pages/src/buildApplication/processVercelFunctions/dedupeEdgeFunctions.ts index 8877014a0..47e7b05d3 100644 --- a/packages/next-on-pages/src/buildApplication/processVercelFunctions/dedupeEdgeFunctions.ts +++ b/packages/next-on-pages/src/buildApplication/processVercelFunctions/dedupeEdgeFunctions.ts @@ -454,6 +454,14 @@ function fixFunctionContents(contents: string): string { '$1null$2null$3null$4', ); + // The workers runtime does not implement `cache` on RequestInit. This is used in Next.js' patched fetch. + // Due to this, we remove the `cache` property from those that Next.js adds to RequestInit. + // https://github.com/vercel/next.js/blob/269114b5cc583f0c91e687c1aeb61503ef681b91/packages/next/src/server/lib/patch-fetch.ts#L304 + contents = contents.replace( + /"cache",("credentials","headers","integrity","keepalive","method","mode","redirect","referrer")/gm, + '$1', + ); + return contents; } diff --git a/packages/next-on-pages/templates/_worker.js/index.ts b/packages/next-on-pages/templates/_worker.js/index.ts index 1789b9a45..ce047063a 100644 --- a/packages/next-on-pages/templates/_worker.js/index.ts +++ b/packages/next-on-pages/templates/_worker.js/index.ts @@ -1,3 +1,4 @@ +import { SUSPENSE_CACHE_URL } from '../cache'; import { handleRequest } from './handleRequest'; import { adjustRequestForVercel, @@ -25,8 +26,10 @@ export default { { status: 503 }, ); } + return envAsyncLocalStorage.run( - { ...env, NODE_ENV: __NODE_ENV__ }, + // NOTE: The `SUSPENSE_CACHE_URL` is used to tell the Next.js Fetch Cache where to send requests. + { ...env, NODE_ENV: __NODE_ENV__, SUSPENSE_CACHE_URL }, async () => { const url = new URL(request.url); if (url.pathname.startsWith('/_next/image')) { diff --git a/packages/next-on-pages/templates/_worker.js/utils/cache.ts b/packages/next-on-pages/templates/_worker.js/utils/cache.ts new file mode 100644 index 000000000..87432d743 --- /dev/null +++ b/packages/next-on-pages/templates/_worker.js/utils/cache.ts @@ -0,0 +1,76 @@ +import type { CacheAdaptor, IncrementalCacheValue } from '../../cache'; +import { SUSPENSE_CACHE_URL } from '../../cache'; +import { CacheApiAdaptor } from '../../cache/cache-api'; + +/** + * Handles an internal request to the suspense cache. + * + * @param request Incoming request to handle. + * @returns Response to the request, or null if the request is not for the suspense cache. + */ +export async function handleSuspenseCacheRequest(request: Request) { + const baseUrl = `https://${SUSPENSE_CACHE_URL}/v1/suspense-cache/`; + if (!request.url.startsWith(baseUrl)) return null; + + try { + const url = new URL(request.url); + const cache = await getSuspenseCacheAdaptor(); + + if (url.pathname === '/v1/suspense-cache/revalidate') { + // Update the revalidated timestamp for the tags in the tags manifest. + const tags = url.searchParams.get('tags')?.split(',') ?? []; + + for (const tag of tags) { + await cache.revalidateTag(tag); + } + + return new Response(null, { status: 200 }); + } + + // Extract the cache key from the URL. + const cacheKey = url.pathname.replace('/v1/suspense-cache/', ''); + if (!cacheKey.length) { + return new Response('Invalid cache key', { status: 400 }); + } + + switch (request.method) { + case 'GET': { + // Retrieve the value from the cache. + const data = await cache.get(cacheKey); + if (!data) return new Response(null, { status: 404 }); + + return new Response(JSON.stringify(data.value), { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'x-vercel-cache-state': 'fresh', + age: `${(Date.now() - (data.lastModified ?? Date.now())) / 1000}`, + }, + }); + } + case 'POST': { + // Update the value in the cache. + const body = await request.json(); + await cache.set(cacheKey, body); + + return new Response(null, { status: 200 }); + } + default: + return new Response(null, { status: 405 }); + } + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + return new Response('Error handling cache request', { status: 500 }); + } +} + +/** + * Gets the cache adaptor to use for the suspense cache. + * + * @returns Adaptor for the suspense cache. + */ +export async function getSuspenseCacheAdaptor(): Promise { + // TODO: Try to lazy import the custom cache adaptor. + return new CacheApiAdaptor(); +} diff --git a/packages/next-on-pages/templates/_worker.js/utils/fetch.ts b/packages/next-on-pages/templates/_worker.js/utils/fetch.ts index 12a1a5911..da353ceb7 100644 --- a/packages/next-on-pages/templates/_worker.js/utils/fetch.ts +++ b/packages/next-on-pages/templates/_worker.js/utils/fetch.ts @@ -1,3 +1,5 @@ +import { handleSuspenseCacheRequest } from './cache'; + /** * Patches the global fetch in ways necessary for Next.js (/next-on-pages) applications * to work @@ -18,10 +20,11 @@ function applyPatch() { globalThis.fetch = async (...args) => { const request = new Request(...args); - const response = await handleInlineAssetRequest(request); - if (response) { - return response; - } + let response = await handleInlineAssetRequest(request); + if (response) return response; + + response = await handleSuspenseCacheRequest(request); + if (response) return response; setRequestUserAgentIfNeeded(request); diff --git a/packages/next-on-pages/templates/cache/adaptor.ts b/packages/next-on-pages/templates/cache/adaptor.ts new file mode 100644 index 000000000..e7af70a71 --- /dev/null +++ b/packages/next-on-pages/templates/cache/adaptor.ts @@ -0,0 +1,243 @@ +// 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'; + +// Set to track the revalidated tags in requests. +const revalidatedTags = new Set(); + +/** 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'; + + /** + * @param ctx The incremental cache context from Next.js. NOTE: This is not currently utilised in NOP. + */ + constructor(protected ctx: Record = {}) {} + + /** + * 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 { + 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): Promise { + throw new Error(`Method not implemented - ${key}, ${value}`); + } + + /** + * 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 { + const newEntry: CacheHandlerValue = { + lastModified: Date.now(), + value, + }; + + // Update the cache entry. + await this.update(key, JSON.stringify(newEntry)); + + switch (newEntry.value?.kind) { + case 'FETCH': { + // Update the tags with the cache key. + const tags = newEntry.value.data.tags ?? []; + await this.setTags(tags, { cacheKey: key }); + + getDerivedTags(tags).forEach(tag => revalidatedTags.delete(tag)); + } + } + } + + /** + * Retrieves an entry from the suspense cache. + * + * @param key Key for the item in the suspense cache. + * @returns The cached value, or null if no entry exists. + */ + public async get(key: string): Promise { + // Get entry from the cache. + const entry = await this.retrieve(key); + 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': { + // Load the tags manifest. + await this.loadTagsManifest(); + + // Check if the cache entry is stale or fresh based on the tags. + const tags = getDerivedTags(data.value.data.tags ?? []); + const isStale = tags.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 { + // 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. + */ + public async loadTagsManifest(): Promise { + 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: {} }; + } + + /** + * Saves the local tags manifest in the suspence cache. + */ + public async saveTagsManifest(): Promise { + 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 { + await this.loadTagsManifest(); + + // 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(); + } +} + +// 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/fda1ecc/packages/next/src/server/response-cache/types.ts#L16 +export type CachedFetchValue = { + kind: 'FETCH'; + data: { + headers: { [k: string]: string }; + body: string; + url: string; + status?: number; + 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; +} diff --git a/packages/next-on-pages/templates/cache/cache-api.ts b/packages/next-on-pages/templates/cache/cache-api.ts new file mode 100644 index 000000000..b393aa669 --- /dev/null +++ b/packages/next-on-pages/templates/cache/cache-api.ts @@ -0,0 +1,42 @@ +import { CacheAdaptor, SUSPENSE_CACHE_URL } from './adaptor'; + +/** Suspense Cache adaptor for the Cache API. */ +export class CacheApiAdaptor extends CacheAdaptor { + /** Name of the cache to open in the Cache API. */ + public cacheName = 'suspense-cache'; + + constructor(ctx: Record = {}) { + super(ctx); + } + + public override async retrieve(key: string) { + const cache = await caches.open(this.cacheName); + + const response = await cache.match(this.buildCacheKey(key)); + return response ? response.text() : null; + } + + public override async update(key: string, value: string) { + const cache = await caches.open(this.cacheName); + + // The max-age to use for the cache entry. + const maxAge = '31536000'; // 1 year + + const response = new Response(value, { + headers: new Headers({ + 'cache-control': `max-age=${maxAge}`, + }), + }); + await cache.put(this.buildCacheKey(key), response); + } + + /** + * 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}`; + } +} diff --git a/packages/next-on-pages/templates/cache/index.ts b/packages/next-on-pages/templates/cache/index.ts new file mode 100644 index 000000000..41c162740 --- /dev/null +++ b/packages/next-on-pages/templates/cache/index.ts @@ -0,0 +1 @@ +export * from './adaptor';