From 786a19505b80f6af7d1a58a3688b89eafa0d7ea4 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 7 Aug 2023 22:01:19 +0100 Subject: [PATCH 01/15] feat: fetch (suspense) cache handling, and `next/cache` support --- .changeset/forty-seas-hug.md | 5 + packages/next-on-pages/env.d.ts | 1 + .../dedupeEdgeFunctions.ts | 8 + .../templates/_worker.js/index.ts | 4 +- .../_worker.js/utils/cache-interface.ts | 178 ++++++++++++++++++ .../templates/_worker.js/utils/cache.ts | 176 +++++++++++++++++ .../templates/_worker.js/utils/fetch.ts | 11 +- .../templates/_worker.js/utils/index.ts | 1 + 8 files changed, 379 insertions(+), 5 deletions(-) create mode 100644 .changeset/forty-seas-hug.md create mode 100644 packages/next-on-pages/templates/_worker.js/utils/cache-interface.ts create mode 100644 packages/next-on-pages/templates/_worker.js/utils/cache.ts 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/env.d.ts b/packages/next-on-pages/env.d.ts index 69ddf1a0f..c84b7a1fb 100644 --- a/packages/next-on-pages/env.d.ts +++ b/packages/next-on-pages/env.d.ts @@ -5,6 +5,7 @@ declare global { npm_config_user_agent?: string; CF_PAGES?: string; SHELL?: string; + KV_SUSPENSE_CACHE?: KVNamespace; [key: string]: string | Fetcher; } } 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..a0d078ef0 100644 --- a/packages/next-on-pages/templates/_worker.js/index.ts +++ b/packages/next-on-pages/templates/_worker.js/index.ts @@ -1,5 +1,6 @@ import { handleRequest } from './handleRequest'; import { + SUSPENSE_CACHE_URL, adjustRequestForVercel, handleImageResizingRequest, patchFetch, @@ -25,8 +26,9 @@ export default { { status: 503 }, ); } + return envAsyncLocalStorage.run( - { ...env, NODE_ENV: __NODE_ENV__ }, + { ...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-interface.ts b/packages/next-on-pages/templates/_worker.js/utils/cache-interface.ts new file mode 100644 index 000000000..5f9b8fa7a --- /dev/null +++ b/packages/next-on-pages/templates/_worker.js/utils/cache-interface.ts @@ -0,0 +1,178 @@ +export const SUSPENSE_CACHE_URL = 'INTERNAL_SUSPENSE_CACHE_HOSTNAME'; + +/** + * Gets the cache interface to use for the suspense cache. + * + * @returns Interface for the suspense cache. + */ +export async function getSuspenseCacheInterface(): Promise { + if (process.env.KV_SUSPENSE_CACHE) { + return new KvCacheInterface(process.env.KV_SUSPENSE_CACHE); + } + + const cacheApi = await caches.open('suspense-cache'); + return new CacheApiInterface(cacheApi); +} + +export class CacheInterface { + public tagsManifest: TagsManifest | undefined; + public tagsManifestKey: string; + + constructor(protected cache: T) { + this.tagsManifestKey = this.buildCacheKey('tags-manifest'); + } + + /** + * 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. + * @param init Options for the cache entry. + */ + public async put( + key: string, + value: string, + init?: RequestInit, + ): Promise { + throw new Error(`Method not implemented, ${key} - ${value} - ${init}`); + } + + /** + * 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 { + throw new Error(`Method not implemented, ${key}`); + } + + /** + * Deletes an entry from the suspense cache. + * + * @param key Key for the item in the suspense cache. + */ + public async delete(key: string): Promise { + throw new Error(`Method not implemented, ${key}`); + } + + /** + * 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}`; + } + + /** + * Loads the tags manifest from the suspense cache. + */ + public async loadTagsManifest(): Promise { + try { + const rawManifest = await this.get(this.tagsManifestKey); + if (rawManifest) { + this.tagsManifest = JSON.parse(rawManifest) as TagsManifest; + } + } catch (e) { + // noop + } + + if (!this.tagsManifest) { + this.tagsManifest = { version: 1, items: {} } satisfies TagsManifest; + } + } + + /** + * Saves the local tags manifest in the suspence cache. + */ + public async saveTagsManifest(): Promise { + if (this.tagsManifest) { + await this.put(this.tagsManifestKey, JSON.stringify(this.tagsManifest), { + headers: new Headers({ 'Cache-Control': 'max-age=31536000' }), + }); + } + } + + /** + * 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(); + } +} + +class CacheApiInterface extends CacheInterface { + constructor(cache: Cache) { + super(cache); + } + + public override async put(key: string, value: string, init?: RequestInit) { + const response = new Response(value, init); + await this.cache.put(key, response); + } + + public override async get(key: string) { + const response = await this.cache.match(key); + return response ? response.text() : null; + } + + public override async delete(key: string) { + await this.cache.delete(key); + } +} + +class KvCacheInterface extends CacheInterface { + constructor(cache: KVNamespace) { + super(cache); + } + + public override async put(key: string, value: string) { + await this.cache.put(key, value); + } + + public override async get(key: string) { + return this.cache.get(key); + } + + public override async delete(key: string) { + await this.cache.delete(key); + } +} + +// TODO: D1 Interface + +// TODO: DO Interface + +type TagsManifest = { + version: 1; + items: { [tag: string]: TagsManifestItem }; +}; + +type TagsManifestItem = { keys: string[]; revalidatedAt?: number }; 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..e10672a10 --- /dev/null +++ b/packages/next-on-pages/templates/_worker.js/utils/cache.ts @@ -0,0 +1,176 @@ +import type { CacheInterface } from './cache-interface'; +import { + SUSPENSE_CACHE_URL, + getSuspenseCacheInterface, +} from './cache-interface'; + +/** + * 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 getSuspenseCacheInterface(); + + 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(',') ?? []; + await cache.setTags(tags, { revalidatedAt: Date.now() }); + + return new Response(null, { status: 200 }); + } + + // Extract the cache key from the URL. + const cacheKeyId = url.pathname.replace('/v1/suspense-cache/', ''); + if (!cacheKeyId.length) { + return new Response('Invalid cache key', { status: 400 }); + } + + const cacheKey = cache.buildCacheKey(cacheKeyId); + + switch (request.method) { + case 'GET': + // Retrieve the value from the cache. + return handleRetrieveEntry(cache, cacheKey); + case 'POST': + // Update the value in the cache. + return handleUpdateEntry(cache, cacheKey, await request.text()); + 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 }); + } +} + +/** + * Retrieves a value from the suspense cache. + * + * @param cache Interface for the suspense cache. + * @param cacheKey Key of the cached value to retrieve. + * @returns Response with the cached value. + */ +async function handleRetrieveEntry(cache: CacheInterface, cacheKey: string) { + // Get entry from the cache. + const entry = await cache.get(cacheKey); + if (!entry) return new Response(null, { status: 404 }); + + let data: CacheEntry; + try { + data = JSON.parse(entry) as CacheEntry; + } catch (e) { + return new Response('Failed to parse cache entry', { status: 400 }); + } + + // Load the tags manifest. + await cache.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 => { + const tagEntry = cache.tagsManifest?.items?.[tag]; + return ( + tagEntry?.revalidatedAt && tagEntry?.revalidatedAt >= data.lastModified + ); + }); + + // Return the value from the cache. + return new Response(JSON.stringify(data.value), { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'x-vercel-cache-state': isStale ? 'stale' : 'fresh', + age: `${(Date.now() - data.lastModified) / 1000}`, + }, + }); +} + +/** + * Updates an entry in the suspense cache. + * + * @param cache Interface for the suspense cache. + * @param cacheKey Key of the cached value to update. + * @param body Body of the request to update the cache entry with. + * @returns Response indicating the success of the operation. + */ +async function handleUpdateEntry( + cache: CacheInterface, + cacheKey: string, + body: string, +) { + const newEntry: CacheEntry = { + lastModified: Date.now(), + value: JSON.parse(body), + }; + + // Update the cache entry. + await cache.put(cacheKey, JSON.stringify(newEntry), { + headers: new Headers({ + 'cache-control': `max-age=${newEntry.value.revalidate}`, + }), + }); + + // Update the tags with the cache key. + const tags = newEntry.value.data.tags ?? []; + await cache.setTags(tags, { cacheKey }); + + return new Response(null, { status: 200 }); +} + +type CacheEntry = { lastModified: number; value: NextCachedFetchValue }; + +// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/response-cache/types.ts +type NextCachedFetchValue = { + kind: 'FETCH'; + data: { + headers: { [k: string]: string }; + body: string; + url: string; + status?: number; + tags?: string[]; + }; + revalidate: number; +}; + +/** + * 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/canary/packages/next/src/server/lib/incremental-cache/utils.ts + * + * @param tags Array of tags. + * @returns Derived tags. + */ +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/_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/_worker.js/utils/index.ts b/packages/next-on-pages/templates/_worker.js/utils/index.ts index 5f03e5e43..c203b17a7 100644 --- a/packages/next-on-pages/templates/_worker.js/utils/index.ts +++ b/packages/next-on-pages/templates/_worker.js/utils/index.ts @@ -5,3 +5,4 @@ export * from './pcre'; export * from './routing'; export * from './images'; export * from './fetch'; +export { SUSPENSE_CACHE_URL } from './cache-interface'; From 0fb3a8701ffc78b3a105b821f76169784139f849 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 8 Aug 2023 15:13:23 +0100 Subject: [PATCH 02/15] fix tag revalidation + stale entry revalidation --- .../templates/_worker.js/utils/cache.ts | 39 +++++++++++++++---- .../templates/_worker.js/utils/fetch.ts | 4 +- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/packages/next-on-pages/templates/_worker.js/utils/cache.ts b/packages/next-on-pages/templates/_worker.js/utils/cache.ts index e10672a10..93e7d96c0 100644 --- a/packages/next-on-pages/templates/_worker.js/utils/cache.ts +++ b/packages/next-on-pages/templates/_worker.js/utils/cache.ts @@ -10,7 +10,10 @@ import { * @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) { +export async function handleSuspenseCacheRequest( + request: Request, + revalidatedTags: Set, +) { const baseUrl = `https://${SUSPENSE_CACHE_URL}/v1/suspense-cache/`; if (!request.url.startsWith(baseUrl)) return null; @@ -23,6 +26,8 @@ export async function handleSuspenseCacheRequest(request: Request) { const tags = url.searchParams.get('tags')?.split(',') ?? []; await cache.setTags(tags, { revalidatedAt: Date.now() }); + tags.forEach(tag => revalidatedTags.add(tag)); + return new Response(null, { status: 200 }); } @@ -37,10 +42,13 @@ export async function handleSuspenseCacheRequest(request: Request) { switch (request.method) { case 'GET': // Retrieve the value from the cache. - return handleRetrieveEntry(cache, cacheKey); + return handleRetrieveEntry(cache, cacheKey, { revalidatedTags }); case 'POST': // Update the value in the cache. - return handleUpdateEntry(cache, cacheKey, await request.text()); + return handleUpdateEntry(cache, cacheKey, { + body: await request.text(), + revalidatedTags, + }); default: return new Response(null, { status: 405 }); } @@ -58,7 +66,11 @@ export async function handleSuspenseCacheRequest(request: Request) { * @param cacheKey Key of the cached value to retrieve. * @returns Response with the cached value. */ -async function handleRetrieveEntry(cache: CacheInterface, cacheKey: string) { +async function handleRetrieveEntry( + cache: CacheInterface, + cacheKey: string, + { revalidatedTags }: { revalidatedTags: Set }, +) { // Get entry from the cache. const entry = await cache.get(cacheKey); if (!entry) return new Response(null, { status: 404 }); @@ -82,13 +94,24 @@ async function handleRetrieveEntry(cache: CacheInterface, cacheKey: string) { ); }); + const cacheAge = + (Date.now() - data.lastModified) / 1000 + + // If the cache entry is stale, add the revalidate interval to properly force a revalidation. + (isStale ? data.value.revalidate : 0); + + if (isStale && tags.some(tag => revalidatedTags.has(tag))) { + return new Response('Forced revalidation for server actions', { + status: 404, + }); + } + // Return the value from the cache. return new Response(JSON.stringify(data.value), { status: 200, headers: { 'Content-Type': 'application/json', - 'x-vercel-cache-state': isStale ? 'stale' : 'fresh', - age: `${(Date.now() - data.lastModified) / 1000}`, + 'x-vercel-cache-state': 'fresh', + age: `${cacheAge}`, }, }); } @@ -104,7 +127,7 @@ async function handleRetrieveEntry(cache: CacheInterface, cacheKey: string) { async function handleUpdateEntry( cache: CacheInterface, cacheKey: string, - body: string, + { body, revalidatedTags }: { body: string; revalidatedTags: Set }, ) { const newEntry: CacheEntry = { lastModified: Date.now(), @@ -122,6 +145,8 @@ async function handleUpdateEntry( const tags = newEntry.value.data.tags ?? []; await cache.setTags(tags, { cacheKey }); + getDerivedTags(tags).forEach(tag => revalidatedTags.delete(tag)); + return new Response(null, { status: 200 }); } 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 da353ceb7..1c32cd906 100644 --- a/packages/next-on-pages/templates/_worker.js/utils/fetch.ts +++ b/packages/next-on-pages/templates/_worker.js/utils/fetch.ts @@ -17,13 +17,15 @@ export function patchFetch(): void { function applyPatch() { const originalFetch = globalThis.fetch; + const revalidatedTags = new Set(); + globalThis.fetch = async (...args) => { const request = new Request(...args); let response = await handleInlineAssetRequest(request); if (response) return response; - response = await handleSuspenseCacheRequest(request); + response = await handleSuspenseCacheRequest(request, revalidatedTags); if (response) return response; setRequestUserAgentIfNeeded(request); From eae7690834d1aa741803639c2c2289dc02840529 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 8 Aug 2023 18:18:27 +0100 Subject: [PATCH 03/15] D1 and R2 cache interfaces --- packages/next-on-pages/env.d.ts | 2 + .../_worker.js/utils/cache-interface.ts | 112 ++++++++++++++---- .../templates/_worker.js/utils/cache.ts | 6 +- 3 files changed, 95 insertions(+), 25 deletions(-) diff --git a/packages/next-on-pages/env.d.ts b/packages/next-on-pages/env.d.ts index c84b7a1fb..4d8f1b754 100644 --- a/packages/next-on-pages/env.d.ts +++ b/packages/next-on-pages/env.d.ts @@ -6,6 +6,8 @@ declare global { CF_PAGES?: string; SHELL?: string; KV_SUSPENSE_CACHE?: KVNamespace; + D1_SUSPENSE_CACHE?: D1Database; + R2_SUSPENSE_CACHE?: R2Bucket; [key: string]: string | Fetcher; } } diff --git a/packages/next-on-pages/templates/_worker.js/utils/cache-interface.ts b/packages/next-on-pages/templates/_worker.js/utils/cache-interface.ts index 5f9b8fa7a..66b3f91fd 100644 --- a/packages/next-on-pages/templates/_worker.js/utils/cache-interface.ts +++ b/packages/next-on-pages/templates/_worker.js/utils/cache-interface.ts @@ -3,24 +3,35 @@ export const SUSPENSE_CACHE_URL = 'INTERNAL_SUSPENSE_CACHE_HOSTNAME'; /** * Gets the cache interface to use for the suspense cache. * + * Prioritises KV > D1 > R2 > Cache API. + * * @returns Interface for the suspense cache. */ export async function getSuspenseCacheInterface(): Promise { if (process.env.KV_SUSPENSE_CACHE) { - return new KvCacheInterface(process.env.KV_SUSPENSE_CACHE); + return new KVCacheInterface(process.env.KV_SUSPENSE_CACHE); + } + + if (process.env.D1_SUSPENSE_CACHE) { + return new D1CacheInterface(process.env.D1_SUSPENSE_CACHE); + } + + if (process.env.R2_SUSPENSE_CACHE) { + return new R2CacheInterface(process.env.R2_SUSPENSE_CACHE); } const cacheApi = await caches.open('suspense-cache'); return new CacheApiInterface(cacheApi); } -export class CacheInterface { +type Caches = Cache | KVNamespace | D1Database | R2Bucket; + +/** Generic interface for the Suspense Cache. */ +export class CacheInterface { public tagsManifest: TagsManifest | undefined; - public tagsManifestKey: string; + public tagsManifestKey = 'tags-manifest'; - constructor(protected cache: T) { - this.tagsManifestKey = this.buildCacheKey('tags-manifest'); - } + constructor(protected cache: T) {} /** * Puts a new entry in the suspense cache. @@ -56,16 +67,6 @@ export class CacheInterface { throw new Error(`Method not implemented, ${key}`); } - /** - * 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}`; - } - /** * Loads the tags manifest from the suspense cache. */ @@ -128,6 +129,7 @@ export class CacheInterface { } } +/** Suspense Cache interface for the Cache API. */ class CacheApiInterface extends CacheInterface { constructor(cache: Cache) { super(cache); @@ -135,20 +137,31 @@ class CacheApiInterface extends CacheInterface { public override async put(key: string, value: string, init?: RequestInit) { const response = new Response(value, init); - await this.cache.put(key, response); + await this.cache.put(this.buildCacheKey(key), response); } public override async get(key: string) { - const response = await this.cache.match(key); + const response = await this.cache.match(this.buildCacheKey(key)); return response ? response.text() : null; } public override async delete(key: string) { - await this.cache.delete(key); + await this.cache.delete(this.buildCacheKey(key)); + } + + /** + * 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}`; } } -class KvCacheInterface extends CacheInterface { +/** Suspense Cache interface for Workers KV. */ +class KVCacheInterface extends CacheInterface { constructor(cache: KVNamespace) { super(cache); } @@ -166,7 +179,64 @@ class KvCacheInterface extends CacheInterface { } } -// TODO: D1 Interface +/** + * Suspense Cache interface for D1. + * + * **Table Creation SQL** + * ```sql + * CREATE TABLE IF NOT EXISTS suspense_cache (key text PRIMARY KEY, value text NOT NULL); + * ``` + */ +class D1CacheInterface extends CacheInterface { + constructor(cache: D1Database) { + super(cache); + } + + public override async put(key: string, value: string) { + const status = await this.cache + .prepare( + `INSERT OR REPLACE INTO suspense_cache (key, value) VALUES (?, ?)`, + ) + .bind(key, value) + .run(); + if (status.error) throw new Error(status.error); + } + + public override async get(key: string) { + const value = await this.cache + .prepare(`SELECT value FROM suspense_cache WHERE key = ?`) + .bind(key) + .first('value'); + return typeof value === 'string' ? value : null; + } + + public override async delete(key: string) { + await this.cache + .prepare(`DELETE FROM suspense_cache WHERE key = ?`) + .bind(key) + .run(); + } +} + +/** Suspense Cache interface for R2. */ +class R2CacheInterface extends CacheInterface { + constructor(cache: R2Bucket) { + super(cache); + } + + public override async put(key: string, value: string) { + await this.cache.put(key, value); + } + + public override async get(key: string) { + const value = await this.cache.get(key); + return value ? value.text() : null; + } + + public override async delete(key: string) { + await this.cache.delete(key); + } +} // TODO: DO Interface diff --git a/packages/next-on-pages/templates/_worker.js/utils/cache.ts b/packages/next-on-pages/templates/_worker.js/utils/cache.ts index 93e7d96c0..d7df916ca 100644 --- a/packages/next-on-pages/templates/_worker.js/utils/cache.ts +++ b/packages/next-on-pages/templates/_worker.js/utils/cache.ts @@ -32,13 +32,11 @@ export async function handleSuspenseCacheRequest( } // Extract the cache key from the URL. - const cacheKeyId = url.pathname.replace('/v1/suspense-cache/', ''); - if (!cacheKeyId.length) { + const cacheKey = url.pathname.replace('/v1/suspense-cache/', ''); + if (!cacheKey.length) { return new Response('Invalid cache key', { status: 400 }); } - const cacheKey = cache.buildCacheKey(cacheKeyId); - switch (request.method) { case 'GET': // Retrieve the value from the cache. From 4ff6b87e335ee21596f1f11dd1e3dd5287998d6e Mon Sep 17 00:00:00 2001 From: James Date: Wed, 9 Aug 2023 12:40:49 +0100 Subject: [PATCH 04/15] docs --- packages/next-on-pages/docs/caching.md | 52 ++++++++++++++++++++++++ packages/next-on-pages/docs/supported.md | 12 +----- 2 files changed, 53 insertions(+), 11 deletions(-) create mode 100644 packages/next-on-pages/docs/caching.md diff --git a/packages/next-on-pages/docs/caching.md b/packages/next-on-pages/docs/caching.md new file mode 100644 index 000000000..43f990cfb --- /dev/null +++ b/packages/next-on-pages/docs/caching.md @@ -0,0 +1,52 @@ +# 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 `@cloudflare/next-on-pages` supports for caching. + +We recommend evaluating each option and choosing the one that best suits your use case, depending on latency and consistency requirements. + +### Workers KV + +[Workers KV](https://developers.cloudflare.com/workers/learning/how-kv-works/) is globally-distributed, low-latency storage option that is ideal for caching data. While it's designed for this use case, KV is an eventually-consistent data store, meaning that it can take up to 60 seconds for changes to propagate globally. This is fine for many use cases, but if you need to ensure that data is updated more frequently globally, you should consider a different storage option. + +1. Create a [new KV Namespace](https://dash.cloudflare.com/?to=/:account/workers/kv/namespaces). +2. Find your [Pages project](https://dash.cloudflare.com/?to=/:account/workers-and-pages) in the Cloudflare dashboard. +3. Go to your Pages project Settings > Functions > KV Namespace Bindings. +4. Add a new binding mapping `KV_SUSPENSE_CACHE` to your created KV Namespace. + +### Cloudflare D1 + +[Cloudflare D1](https://developers.cloudflare.com/d1/) is a read-replicated, serverless database offering that uses SQLite. Unlike KV, it is strongly-consistent, meaning that changes will be accessible instantly, globally. However, while being read-replicated, it is not distributed in every data center, so there could be a minor impact on latency. + +1. Create a [new D1 Database](https://dash.cloudflare.com/?to=/:account/workers/d1) if you don't already have one. +2. Create a new table in your database by clicking "Create table". + 2.1. Give your table the name `suspense_cache`. + 2.2. Add a row with the name `key` and type `text`, and set it as the primary key. + 2.3. Add a row with the name `value` and type `text`. +3. Find your [Pages project](https://dash.cloudflare.com/?to=/:account/workers-and-pages) in the Cloudflare dashboard. +4. Go to your Pages project Settings > Functions > D1 Database Bindings. +5. Add a new binding mapping `D1_SUSPENSE_CACHE` to your D1 Database. + +If you would like to create the table with SQL instead, you can use the following query: + +```sql +CREATE TABLE IF NOT EXISTS suspense_cache (key text PRIMARY KEY, value text NOT NULL); +``` + +### Cloudflare R2 + +[Cloudflare R2](https://developers.cloudflare.com/r2/) is an S3-compatible object storage offering that is globally distributed and strongly consistent. It is ideal for storing large amounts of unstructured data, but is likely to experience higher latency that KV or D1. + +1. Create a [new R2 Bucket](https://dash.cloudflare.com/?to=/:account/r2/overview). +2. Find your [Pages project](https://dash.cloudflare.com/?to=/:account/workers-and-pages) in the Cloudflare dashboard. +3. Go to your Pages project Settings > Functions > R2 Bucket Bindings. +4. Add a new binding mapping `R2_SUSPENSE_CACHE` to your created R2 Bucket. + +### 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. Due to this limitation, it is not a recommended storage option for Next.js caching and data revalidation - we suggest using one of the other options above. + +No additional setup is required to use the Cache API. 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). From d1ce7f3554a6a05c8d606c173b70100e345dd69e Mon Sep 17 00:00:00 2001 From: James Date: Thu, 10 Aug 2023 23:06:27 +0100 Subject: [PATCH 05/15] Apply suggestions from code review Co-authored-by: Dario Piotrowicz --- packages/next-on-pages/templates/_worker.js/utils/cache.ts | 4 ++-- packages/next-on-pages/templates/_worker.js/utils/index.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/next-on-pages/templates/_worker.js/utils/cache.ts b/packages/next-on-pages/templates/_worker.js/utils/cache.ts index d7df916ca..ffb2ebb4f 100644 --- a/packages/next-on-pages/templates/_worker.js/utils/cache.ts +++ b/packages/next-on-pages/templates/_worker.js/utils/cache.ts @@ -150,7 +150,7 @@ async function handleUpdateEntry( type CacheEntry = { lastModified: number; value: NextCachedFetchValue }; -// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/response-cache/types.ts +// https://github.com/vercel/next.js/blob/fda1ecc/packages/next/src/server/response-cache/types.ts#L16 type NextCachedFetchValue = { kind: 'FETCH'; data: { @@ -166,7 +166,7 @@ type NextCachedFetchValue = { /** * 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/canary/packages/next/src/server/lib/incremental-cache/utils.ts + * @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. diff --git a/packages/next-on-pages/templates/_worker.js/utils/index.ts b/packages/next-on-pages/templates/_worker.js/utils/index.ts index c203b17a7..929f6d626 100644 --- a/packages/next-on-pages/templates/_worker.js/utils/index.ts +++ b/packages/next-on-pages/templates/_worker.js/utils/index.ts @@ -5,4 +5,4 @@ export * from './pcre'; export * from './routing'; export * from './images'; export * from './fetch'; -export { SUSPENSE_CACHE_URL } from './cache-interface'; +export * from './cache-interface'; From b1577fa1d87b63e4afb84c52722281221df2517e Mon Sep 17 00:00:00 2001 From: James Date: Tue, 15 Aug 2023 11:53:48 +0100 Subject: [PATCH 06/15] commit desktop changes so i can move to my laptop --- .../_worker.js/utils/cache-interface.ts | 374 ++++++++++++------ .../templates/_worker.js/utils/cache.ts | 58 +-- 2 files changed, 257 insertions(+), 175 deletions(-) diff --git a/packages/next-on-pages/templates/_worker.js/utils/cache-interface.ts b/packages/next-on-pages/templates/_worker.js/utils/cache-interface.ts index 66b3f91fd..0b353c1a7 100644 --- a/packages/next-on-pages/templates/_worker.js/utils/cache-interface.ts +++ b/packages/next-on-pages/templates/_worker.js/utils/cache-interface.ts @@ -3,49 +3,67 @@ export const SUSPENSE_CACHE_URL = 'INTERNAL_SUSPENSE_CACHE_HOSTNAME'; /** * Gets the cache interface to use for the suspense cache. * - * Prioritises KV > D1 > R2 > Cache API. - * * @returns Interface for the suspense cache. */ export async function getSuspenseCacheInterface(): Promise { - if (process.env.KV_SUSPENSE_CACHE) { - return new KVCacheInterface(process.env.KV_SUSPENSE_CACHE); - } + // TODO: import lazy loaded cache interface. - if (process.env.D1_SUSPENSE_CACHE) { - return new D1CacheInterface(process.env.D1_SUSPENSE_CACHE); - } - - if (process.env.R2_SUSPENSE_CACHE) { - return new R2CacheInterface(process.env.R2_SUSPENSE_CACHE); - } - - const cacheApi = await caches.open('suspense-cache'); - return new CacheApiInterface(cacheApi); + return new CacheApiInterface({}); } -type Caches = Cache | KVNamespace | D1Database | R2Bucket; +const revalidatedTags = new Set(); /** Generic interface for the Suspense Cache. */ -export class CacheInterface { +export class CacheInterface { public tagsManifest: TagsManifest | undefined; public tagsManifestKey = 'tags-manifest'; - constructor(protected cache: T) {} + constructor(protected options: 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 { + 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. - * @param init Options for the cache entry. */ - public async put( - key: string, - value: string, - init?: RequestInit, - ): Promise { - throw new Error(`Method not implemented, ${key} - ${value} - ${init}`); + 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)); + } + } } /** @@ -54,17 +72,57 @@ export class CacheInterface { * @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 { - throw new Error(`Method not implemented, ${key}`); + 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) { + // return new Response('Failed to parse cache entry', { status: 400 }); + // TODO: Debug message + 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; + } + } } /** - * Deletes an entry from the suspense cache. + * Revalidates a tag in the suspense cache's tags manifest. * - * @param key Key for the item in the suspense cache. + * @param tag Tag to revalidate. */ - public async delete(key: string): Promise { - throw new Error(`Method not implemented, ${key}`); + 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); } /** @@ -72,7 +130,7 @@ export class CacheInterface { */ public async loadTagsManifest(): Promise { try { - const rawManifest = await this.get(this.tagsManifestKey); + const rawManifest = await this.retrieve(this.tagsManifestKey); if (rawManifest) { this.tagsManifest = JSON.parse(rawManifest) as TagsManifest; } @@ -90,9 +148,8 @@ export class CacheInterface { */ public async saveTagsManifest(): Promise { if (this.tagsManifest) { - await this.put(this.tagsManifestKey, JSON.stringify(this.tagsManifest), { - headers: new Headers({ 'Cache-Control': 'max-age=31536000' }), - }); + const newValue = JSON.stringify(this.tagsManifest); + await this.update(this.tagsManifestKey, newValue); } } @@ -104,7 +161,7 @@ export class CacheInterface { */ public async setTags( tags: string[], - { cacheKey, revalidatedAt }: { cacheKey?: string; revalidatedAt?: number }, + { cacheKey, revalidatedAt }: { cacheKey?: string; revalidatedAt?: number } ): Promise { await this.loadTagsManifest(); @@ -129,24 +186,35 @@ export class CacheInterface { } } -/** Suspense Cache interface for the Cache API. */ -class CacheApiInterface extends CacheInterface { - constructor(cache: Cache) { - super(cache); +// /** Suspense Cache interface for the Cache API. */ +class CacheApiInterface extends CacheInterface { + constructor(options: unknown) { + super(options); } - public override async put(key: string, value: string, init?: RequestInit) { - const response = new Response(value, init); - await this.cache.put(this.buildCacheKey(key), response); - } + public override async retrieve(key: string) { + const cache = await caches.open('suspense-cache'); - public override async get(key: string) { - const response = await this.cache.match(this.buildCacheKey(key)); + const response = await cache.match(this.buildCacheKey(key)); return response ? response.text() : null; } - public override async delete(key: string) { - await this.cache.delete(this.buildCacheKey(key)); + public override async update(key: string, value: string) { + const cache = await caches.open('suspense-cache'); + + // Figure out the max-age for the cache entry. + const entry = JSON.parse(value) as IncrementalCacheValue; + const maxAge = + key === this.tagsManifestKey || entry.kind !== 'FETCH' + ? '31536000' + : entry.revalidate; + + const response = new Response(value, { + headers: new Headers({ + 'cache-control': `max-age=${maxAge}`, + }), + }); + await cache.put(this.buildCacheKey(key), response); } /** @@ -160,89 +228,153 @@ class CacheApiInterface extends CacheInterface { } } -/** Suspense Cache interface for Workers KV. */ -class KVCacheInterface extends CacheInterface { - constructor(cache: KVNamespace) { - super(cache); - } +// /** Suspense Cache interface for Workers KV. */ +// class KVCacheInterface extends CacheInterface { +// constructor(cache: KVNamespace) { +// super(cache); +// } + +// public override async put(key: string, value: string) { +// await this.cache.put(key, value); +// } + +// public override async get(key: string) { +// return this.cache.get(key); +// } + +// public override async delete(key: string) { +// await this.cache.delete(key); +// } +// } + +// /** +// * Suspense Cache interface for D1. +// * +// * **Table Creation SQL** +// * ```sql +// * CREATE TABLE IF NOT EXISTS suspense_cache (key text PRIMARY KEY, value text NOT NULL); +// * ``` +// */ +// class D1CacheInterface extends CacheInterface { +// constructor(cache: D1Database) { +// super(cache); +// } + +// public override async put(key: string, value: string) { +// const status = await this.cache +// .prepare( +// `INSERT OR REPLACE INTO suspense_cache (key, value) VALUES (?, ?)` +// ) +// .bind(key, value) +// .run(); +// if (status.error) throw new Error(status.error); +// } + +// public override async get(key: string) { +// const value = await this.cache +// .prepare(`SELECT value FROM suspense_cache WHERE key = ?`) +// .bind(key) +// .first('value'); +// return typeof value === 'string' ? value : null; +// } + +// public override async delete(key: string) { +// await this.cache +// .prepare(`DELETE FROM suspense_cache WHERE key = ?`) +// .bind(key) +// .run(); +// } +// } + +// /** Suspense Cache interface for R2. */ +// class R2CacheInterface extends CacheInterface { +// constructor(cache: R2Bucket) { +// super(cache); +// } + +// public override async put(key: string, value: string) { +// await this.cache.put(key, value); +// } + +// public override async get(key: string) { +// const value = await this.cache.get(key); +// return value ? value.text() : null; +// } + +// public override async delete(key: string) { +// await this.cache.delete(key); +// } +// } + +// https://github.com/vercel/next.js/blob/261db49/packages/next/src/server/lib/incremental-cache/file-system-cache.ts#L17 +type TagsManifest = { + version: 1; + items: { [tag: string]: TagsManifestItem }; +}; +type TagsManifestItem = { keys: string[]; revalidatedAt?: number }; - public override async put(key: string, value: string) { - await this.cache.put(key, value); - } +// https://github.com/vercel/next.js/blob/fda1ecc/packages/next/src/server/response-cache/types.ts#L16 + +type CachedFetchValue = { + kind: 'FETCH'; + data: { + headers: { [k: string]: string }; + body: string; + url: string; + status?: number; + tags?: string[]; + }; + revalidate: number; +}; - public override async get(key: string) { - return this.cache.get(key); - } +type CachedImageValue = { + kind: 'IMAGE'; + etag: string; + buffer: Buffer; + extension: string; + isMiss?: boolean; + isStale?: boolean; +}; - public override async delete(key: string) { - await this.cache.delete(key); - } -} +type CacheHandlerValue = { + lastModified?: number; + age?: number; + cacheState?: string; + value: IncrementalCacheValue | null; +}; +type IncrementalCacheValue = CachedImageValue | CachedFetchValue; /** - * Suspense Cache interface for D1. + * Derives a list of tags from the given tags. This is taken from the Next.js source code. * - * **Table Creation SQL** - * ```sql - * CREATE TABLE IF NOT EXISTS suspense_cache (key text PRIMARY KEY, value text NOT NULL); - * ``` + * @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. */ -class D1CacheInterface extends CacheInterface { - constructor(cache: D1Database) { - super(cache); - } - - public override async put(key: string, value: string) { - const status = await this.cache - .prepare( - `INSERT OR REPLACE INTO suspense_cache (key, value) VALUES (?, ?)`, - ) - .bind(key, value) - .run(); - if (status.error) throw new Error(status.error); - } - - public override async get(key: string) { - const value = await this.cache - .prepare(`SELECT value FROM suspense_cache WHERE key = ?`) - .bind(key) - .first('value'); - return typeof value === 'string' ? value : null; - } +function getDerivedTags(tags: string[]): string[] { + const derivedTags: string[] = ['/']; - public override async delete(key: string) { - await this.cache - .prepare(`DELETE FROM suspense_cache WHERE key = ?`) - .bind(key) - .run(); - } -} - -/** Suspense Cache interface for R2. */ -class R2CacheInterface extends CacheInterface { - constructor(cache: R2Bucket) { - super(cache); - } + for (const tag of tags || []) { + if (tag.startsWith('/')) { + const pathnameParts = tag.split('/'); - public override async put(key: string, value: string) { - await this.cache.put(key, value); - } + // 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('/'); - public override async get(key: string) { - const value = await this.cache.get(key); - return value ? value.text() : null; - } + if (curPathname) { + derivedTags.push(curPathname); - public override async delete(key: string) { - await this.cache.delete(key); + if (!derivedTags.includes(curPathname)) { + derivedTags.push(curPathname); + } + } + } + } else if (!derivedTags.includes(tag)) { + derivedTags.push(tag); + } } + return derivedTags; } - -// TODO: DO Interface - -type TagsManifest = { - version: 1; - items: { [tag: string]: TagsManifestItem }; -}; - -type TagsManifestItem = { keys: string[]; revalidatedAt?: number }; diff --git a/packages/next-on-pages/templates/_worker.js/utils/cache.ts b/packages/next-on-pages/templates/_worker.js/utils/cache.ts index ffb2ebb4f..4627b6a6a 100644 --- a/packages/next-on-pages/templates/_worker.js/utils/cache.ts +++ b/packages/next-on-pages/templates/_worker.js/utils/cache.ts @@ -12,7 +12,7 @@ import { */ export async function handleSuspenseCacheRequest( request: Request, - revalidatedTags: Set, + revalidatedTags: Set ) { const baseUrl = `https://${SUSPENSE_CACHE_URL}/v1/suspense-cache/`; if (!request.url.startsWith(baseUrl)) return null; @@ -67,7 +67,7 @@ export async function handleSuspenseCacheRequest( async function handleRetrieveEntry( cache: CacheInterface, cacheKey: string, - { revalidatedTags }: { revalidatedTags: Set }, + { revalidatedTags }: { revalidatedTags: Set } ) { // Get entry from the cache. const entry = await cache.get(cacheKey); @@ -125,7 +125,7 @@ async function handleRetrieveEntry( async function handleUpdateEntry( cache: CacheInterface, cacheKey: string, - { body, revalidatedTags }: { body: string; revalidatedTags: Set }, + { body, revalidatedTags }: { body: string; revalidatedTags: Set } ) { const newEntry: CacheEntry = { lastModified: Date.now(), @@ -133,7 +133,7 @@ async function handleUpdateEntry( }; // Update the cache entry. - await cache.put(cacheKey, JSON.stringify(newEntry), { + await cache.set(cacheKey, JSON.stringify(newEntry), { headers: new Headers({ 'cache-control': `max-age=${newEntry.value.revalidate}`, }), @@ -147,53 +147,3 @@ async function handleUpdateEntry( return new Response(null, { status: 200 }); } - -type CacheEntry = { lastModified: number; value: NextCachedFetchValue }; - -// https://github.com/vercel/next.js/blob/fda1ecc/packages/next/src/server/response-cache/types.ts#L16 -type NextCachedFetchValue = { - kind: 'FETCH'; - data: { - headers: { [k: string]: string }; - body: string; - url: string; - status?: number; - tags?: string[]; - }; - revalidate: number; -}; - -/** - * 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. - */ -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; -} From 8e7ab8787375df7f9ffbdab647afaf6f640d1f47 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 15 Aug 2023 23:56:11 +0100 Subject: [PATCH 07/15] done i think? --- .../templates/_worker.js/index.ts | 2 +- .../templates/_worker.js/utils/cache.ts | 135 ++++---------- .../templates/_worker.js/utils/fetch.ts | 4 +- .../templates/_worker.js/utils/index.ts | 1 - .../templates/cache/cache-api.ts | 47 +++++ .../next-on-pages/templates/cache/index.ts | 1 + .../cache-interface.ts => cache/interface.ts} | 168 ++---------------- 7 files changed, 98 insertions(+), 260 deletions(-) create mode 100644 packages/next-on-pages/templates/cache/cache-api.ts create mode 100644 packages/next-on-pages/templates/cache/index.ts rename packages/next-on-pages/templates/{_worker.js/utils/cache-interface.ts => cache/interface.ts} (59%) diff --git a/packages/next-on-pages/templates/_worker.js/index.ts b/packages/next-on-pages/templates/_worker.js/index.ts index a0d078ef0..bb500919a 100644 --- a/packages/next-on-pages/templates/_worker.js/index.ts +++ b/packages/next-on-pages/templates/_worker.js/index.ts @@ -1,6 +1,6 @@ +import { SUSPENSE_CACHE_URL } from '../cache'; import { handleRequest } from './handleRequest'; import { - SUSPENSE_CACHE_URL, adjustRequestForVercel, handleImageResizingRequest, patchFetch, diff --git a/packages/next-on-pages/templates/_worker.js/utils/cache.ts b/packages/next-on-pages/templates/_worker.js/utils/cache.ts index 4627b6a6a..87fb7625e 100644 --- a/packages/next-on-pages/templates/_worker.js/utils/cache.ts +++ b/packages/next-on-pages/templates/_worker.js/utils/cache.ts @@ -1,19 +1,16 @@ -import type { CacheInterface } from './cache-interface'; import { + CacheInterface, + IncrementalCacheValue, SUSPENSE_CACHE_URL, - getSuspenseCacheInterface, -} from './cache-interface'; - +} from '../../cache'; +import { CacheApiInterface } 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, - revalidatedTags: Set -) { +export async function handleSuspenseCacheRequest(request: Request) { const baseUrl = `https://${SUSPENSE_CACHE_URL}/v1/suspense-cache/`; if (!request.url.startsWith(baseUrl)) return null; @@ -24,9 +21,10 @@ export async function handleSuspenseCacheRequest( 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(',') ?? []; - await cache.setTags(tags, { revalidatedAt: Date.now() }); - tags.forEach(tag => revalidatedTags.add(tag)); + for (const tag of tags) { + await cache.revalidateTag(tag); + } return new Response(null, { status: 200 }); } @@ -38,15 +36,27 @@ export async function handleSuspenseCacheRequest( } switch (request.method) { - case 'GET': + case 'GET': { // Retrieve the value from the cache. - return handleRetrieveEntry(cache, cacheKey, { revalidatedTags }); - case 'POST': - // Update the value in the cache. - return handleUpdateEntry(cache, cacheKey, { - body: await request.text(), - revalidatedTags, + 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 }); } @@ -58,92 +68,11 @@ export async function handleSuspenseCacheRequest( } /** - * Retrieves a value from the suspense cache. + * Gets the cache interface to use for the suspense cache. * - * @param cache Interface for the suspense cache. - * @param cacheKey Key of the cached value to retrieve. - * @returns Response with the cached value. + * @returns Interface for the suspense cache. */ -async function handleRetrieveEntry( - cache: CacheInterface, - cacheKey: string, - { revalidatedTags }: { revalidatedTags: Set } -) { - // Get entry from the cache. - const entry = await cache.get(cacheKey); - if (!entry) return new Response(null, { status: 404 }); - - let data: CacheEntry; - try { - data = JSON.parse(entry) as CacheEntry; - } catch (e) { - return new Response('Failed to parse cache entry', { status: 400 }); - } - - // Load the tags manifest. - await cache.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 => { - const tagEntry = cache.tagsManifest?.items?.[tag]; - return ( - tagEntry?.revalidatedAt && tagEntry?.revalidatedAt >= data.lastModified - ); - }); - - const cacheAge = - (Date.now() - data.lastModified) / 1000 + - // If the cache entry is stale, add the revalidate interval to properly force a revalidation. - (isStale ? data.value.revalidate : 0); - - if (isStale && tags.some(tag => revalidatedTags.has(tag))) { - return new Response('Forced revalidation for server actions', { - status: 404, - }); - } - - // Return the value from the cache. - return new Response(JSON.stringify(data.value), { - status: 200, - headers: { - 'Content-Type': 'application/json', - 'x-vercel-cache-state': 'fresh', - age: `${cacheAge}`, - }, - }); -} - -/** - * Updates an entry in the suspense cache. - * - * @param cache Interface for the suspense cache. - * @param cacheKey Key of the cached value to update. - * @param body Body of the request to update the cache entry with. - * @returns Response indicating the success of the operation. - */ -async function handleUpdateEntry( - cache: CacheInterface, - cacheKey: string, - { body, revalidatedTags }: { body: string; revalidatedTags: Set } -) { - const newEntry: CacheEntry = { - lastModified: Date.now(), - value: JSON.parse(body), - }; - - // Update the cache entry. - await cache.set(cacheKey, JSON.stringify(newEntry), { - headers: new Headers({ - 'cache-control': `max-age=${newEntry.value.revalidate}`, - }), - }); - - // Update the tags with the cache key. - const tags = newEntry.value.data.tags ?? []; - await cache.setTags(tags, { cacheKey }); - - getDerivedTags(tags).forEach(tag => revalidatedTags.delete(tag)); - - return new Response(null, { status: 200 }); +export async function getSuspenseCacheInterface(): Promise { + // TODO: Try to lazy import the custom cache interface. + return new CacheApiInterface(); } 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 1c32cd906..da353ceb7 100644 --- a/packages/next-on-pages/templates/_worker.js/utils/fetch.ts +++ b/packages/next-on-pages/templates/_worker.js/utils/fetch.ts @@ -17,15 +17,13 @@ export function patchFetch(): void { function applyPatch() { const originalFetch = globalThis.fetch; - const revalidatedTags = new Set(); - globalThis.fetch = async (...args) => { const request = new Request(...args); let response = await handleInlineAssetRequest(request); if (response) return response; - response = await handleSuspenseCacheRequest(request, revalidatedTags); + response = await handleSuspenseCacheRequest(request); if (response) return response; setRequestUserAgentIfNeeded(request); diff --git a/packages/next-on-pages/templates/_worker.js/utils/index.ts b/packages/next-on-pages/templates/_worker.js/utils/index.ts index 929f6d626..5f03e5e43 100644 --- a/packages/next-on-pages/templates/_worker.js/utils/index.ts +++ b/packages/next-on-pages/templates/_worker.js/utils/index.ts @@ -5,4 +5,3 @@ export * from './pcre'; export * from './routing'; export * from './images'; export * from './fetch'; -export * from './cache-interface'; 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..8c68e7243 --- /dev/null +++ b/packages/next-on-pages/templates/cache/cache-api.ts @@ -0,0 +1,47 @@ +import type { IncrementalCacheValue } from './interface'; +import { CacheInterface, SUSPENSE_CACHE_URL } from './interface'; +import { withMemoryInterfaceInDev } from './memory'; + +/** Suspense Cache interface for the Cache API. */ +export class CacheApiInterface extends CacheInterface { + constructor(ctx: Record = {}) { + super(ctx); + } + + public override async retrieve(key: string) { + const cache = await caches.open('suspense-cache'); + + 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('suspense-cache'); + + // Figure out the max-age for the cache entry. + const entry = JSON.parse(value) as IncrementalCacheValue; + const maxAge = + key === this.tagsManifestKey || entry.kind !== 'FETCH' + ? '31536000' + : entry.revalidate; + + 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}`; + } +} + +export default withMemoryInterfaceInDev; 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..fc141f790 --- /dev/null +++ b/packages/next-on-pages/templates/cache/index.ts @@ -0,0 +1 @@ +export * from './interface'; diff --git a/packages/next-on-pages/templates/_worker.js/utils/cache-interface.ts b/packages/next-on-pages/templates/cache/interface.ts similarity index 59% rename from packages/next-on-pages/templates/_worker.js/utils/cache-interface.ts rename to packages/next-on-pages/templates/cache/interface.ts index 0b353c1a7..cc3bd45b1 100644 --- a/packages/next-on-pages/templates/_worker.js/utils/cache-interface.ts +++ b/packages/next-on-pages/templates/cache/interface.ts @@ -1,24 +1,19 @@ export const SUSPENSE_CACHE_URL = 'INTERNAL_SUSPENSE_CACHE_HOSTNAME'; -/** - * Gets the cache interface to use for the suspense cache. - * - * @returns Interface for the suspense cache. - */ -export async function getSuspenseCacheInterface(): Promise { - // TODO: import lazy loaded cache interface. - - return new CacheApiInterface({}); -} - +// Set to track the revalidated tags in requests. const revalidatedTags = new Set(); /** Generic interface for the Suspense Cache. */ export class CacheInterface { + /** The tags manifest for fetch calls. */ public tagsManifest: TagsManifest | undefined; + /** The key used for the tags manifest in the cache. */ public tagsManifestKey = 'tags-manifest'; - constructor(protected options: unknown) {} + /** + * @param ctx The incremental cache context. + */ + constructor(protected ctx: Record = {}) {} /** * Retrieves an entry from the storage mechanism. @@ -81,8 +76,7 @@ export class CacheInterface { try { data = JSON.parse(entry) as CacheHandlerValue; } catch (e) { - // return new Response('Failed to parse cache entry', { status: 400 }); - // TODO: Debug message + // Failed to parse the cache entry, so it's invalid. return null; } @@ -139,7 +133,7 @@ export class CacheInterface { } if (!this.tagsManifest) { - this.tagsManifest = { version: 1, items: {} } satisfies TagsManifest; + this.tagsManifest = { version: 1, items: {} }; } } @@ -161,7 +155,7 @@ export class CacheInterface { */ public async setTags( tags: string[], - { cacheKey, revalidatedAt }: { cacheKey?: string; revalidatedAt?: number } + { cacheKey, revalidatedAt }: { cacheKey?: string; revalidatedAt?: number }, ): Promise { await this.loadTagsManifest(); @@ -186,136 +180,15 @@ export class CacheInterface { } } -// /** Suspense Cache interface for the Cache API. */ -class CacheApiInterface extends CacheInterface { - constructor(options: unknown) { - super(options); - } - - public override async retrieve(key: string) { - const cache = await caches.open('suspense-cache'); - - 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('suspense-cache'); - - // Figure out the max-age for the cache entry. - const entry = JSON.parse(value) as IncrementalCacheValue; - const maxAge = - key === this.tagsManifestKey || entry.kind !== 'FETCH' - ? '31536000' - : entry.revalidate; - - 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}`; - } -} - -// /** Suspense Cache interface for Workers KV. */ -// class KVCacheInterface extends CacheInterface { -// constructor(cache: KVNamespace) { -// super(cache); -// } - -// public override async put(key: string, value: string) { -// await this.cache.put(key, value); -// } - -// public override async get(key: string) { -// return this.cache.get(key); -// } - -// public override async delete(key: string) { -// await this.cache.delete(key); -// } -// } - -// /** -// * Suspense Cache interface for D1. -// * -// * **Table Creation SQL** -// * ```sql -// * CREATE TABLE IF NOT EXISTS suspense_cache (key text PRIMARY KEY, value text NOT NULL); -// * ``` -// */ -// class D1CacheInterface extends CacheInterface { -// constructor(cache: D1Database) { -// super(cache); -// } - -// public override async put(key: string, value: string) { -// const status = await this.cache -// .prepare( -// `INSERT OR REPLACE INTO suspense_cache (key, value) VALUES (?, ?)` -// ) -// .bind(key, value) -// .run(); -// if (status.error) throw new Error(status.error); -// } - -// public override async get(key: string) { -// const value = await this.cache -// .prepare(`SELECT value FROM suspense_cache WHERE key = ?`) -// .bind(key) -// .first('value'); -// return typeof value === 'string' ? value : null; -// } - -// public override async delete(key: string) { -// await this.cache -// .prepare(`DELETE FROM suspense_cache WHERE key = ?`) -// .bind(key) -// .run(); -// } -// } - -// /** Suspense Cache interface for R2. */ -// class R2CacheInterface extends CacheInterface { -// constructor(cache: R2Bucket) { -// super(cache); -// } - -// public override async put(key: string, value: string) { -// await this.cache.put(key, value); -// } - -// public override async get(key: string) { -// const value = await this.cache.get(key); -// return value ? value.text() : null; -// } - -// public override async delete(key: string) { -// await this.cache.delete(key); -// } -// } - // https://github.com/vercel/next.js/blob/261db49/packages/next/src/server/lib/incremental-cache/file-system-cache.ts#L17 -type TagsManifest = { +export type TagsManifest = { version: 1; items: { [tag: string]: TagsManifestItem }; }; -type TagsManifestItem = { keys: string[]; revalidatedAt?: number }; +export type TagsManifestItem = { keys: string[]; revalidatedAt?: number }; // https://github.com/vercel/next.js/blob/fda1ecc/packages/next/src/server/response-cache/types.ts#L16 - -type CachedFetchValue = { +export type CachedFetchValue = { kind: 'FETCH'; data: { headers: { [k: string]: string }; @@ -327,22 +200,13 @@ type CachedFetchValue = { revalidate: number; }; -type CachedImageValue = { - kind: 'IMAGE'; - etag: string; - buffer: Buffer; - extension: string; - isMiss?: boolean; - isStale?: boolean; -}; - -type CacheHandlerValue = { +export type CacheHandlerValue = { lastModified?: number; age?: number; cacheState?: string; value: IncrementalCacheValue | null; }; -type IncrementalCacheValue = CachedImageValue | CachedFetchValue; +export type IncrementalCacheValue = CachedFetchValue; /** * Derives a list of tags from the given tags. This is taken from the Next.js source code. @@ -352,7 +216,7 @@ type IncrementalCacheValue = CachedImageValue | CachedFetchValue; * @param tags Array of tags. * @returns Derived tags. */ -function getDerivedTags(tags: string[]): string[] { +export function getDerivedTags(tags: string[]): string[] { const derivedTags: string[] = ['/']; for (const tag of tags || []) { From 3e56e3cc4fc49171e8bbcdff69dba8acd6badbb8 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 16 Aug 2023 00:00:44 +0100 Subject: [PATCH 08/15] docs --- .changeset/few-icons-shop.md | 5 +++ packages/next-on-pages/docs/caching.md | 45 +++----------------------- 2 files changed, 9 insertions(+), 41 deletions(-) create mode 100644 .changeset/few-icons-shop.md 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/packages/next-on-pages/docs/caching.md b/packages/next-on-pages/docs/caching.md index 43f990cfb..4426be304 100644 --- a/packages/next-on-pages/docs/caching.md +++ b/packages/next-on-pages/docs/caching.md @@ -4,49 +4,12 @@ ## Storage Options -There are various different bindings and storage options that `@cloudflare/next-on-pages` supports for caching. +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. -We recommend evaluating each option and choosing the one that best suits your use case, depending on latency and consistency requirements. - -### Workers KV - -[Workers KV](https://developers.cloudflare.com/workers/learning/how-kv-works/) is globally-distributed, low-latency storage option that is ideal for caching data. While it's designed for this use case, KV is an eventually-consistent data store, meaning that it can take up to 60 seconds for changes to propagate globally. This is fine for many use cases, but if you need to ensure that data is updated more frequently globally, you should consider a different storage option. - -1. Create a [new KV Namespace](https://dash.cloudflare.com/?to=/:account/workers/kv/namespaces). -2. Find your [Pages project](https://dash.cloudflare.com/?to=/:account/workers-and-pages) in the Cloudflare dashboard. -3. Go to your Pages project Settings > Functions > KV Namespace Bindings. -4. Add a new binding mapping `KV_SUSPENSE_CACHE` to your created KV Namespace. - -### Cloudflare D1 - -[Cloudflare D1](https://developers.cloudflare.com/d1/) is a read-replicated, serverless database offering that uses SQLite. Unlike KV, it is strongly-consistent, meaning that changes will be accessible instantly, globally. However, while being read-replicated, it is not distributed in every data center, so there could be a minor impact on latency. - -1. Create a [new D1 Database](https://dash.cloudflare.com/?to=/:account/workers/d1) if you don't already have one. -2. Create a new table in your database by clicking "Create table". - 2.1. Give your table the name `suspense_cache`. - 2.2. Add a row with the name `key` and type `text`, and set it as the primary key. - 2.3. Add a row with the name `value` and type `text`. -3. Find your [Pages project](https://dash.cloudflare.com/?to=/:account/workers-and-pages) in the Cloudflare dashboard. -4. Go to your Pages project Settings > Functions > D1 Database Bindings. -5. Add a new binding mapping `D1_SUSPENSE_CACHE` to your D1 Database. - -If you would like to create the table with SQL instead, you can use the following query: - -```sql -CREATE TABLE IF NOT EXISTS suspense_cache (key text PRIMARY KEY, value text NOT NULL); -``` - -### Cloudflare R2 - -[Cloudflare R2](https://developers.cloudflare.com/r2/) is an S3-compatible object storage offering that is globally distributed and strongly consistent. It is ideal for storing large amounts of unstructured data, but is likely to experience higher latency that KV or D1. - -1. Create a [new R2 Bucket](https://dash.cloudflare.com/?to=/:account/r2/overview). -2. Find your [Pages project](https://dash.cloudflare.com/?to=/:account/workers-and-pages) in the Cloudflare dashboard. -3. Go to your Pages project Settings > Functions > R2 Bucket Bindings. -4. Add a new binding mapping `R2_SUSPENSE_CACHE` to your created R2 Bucket. +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. Due to this limitation, it is not a recommended storage option for Next.js caching and data revalidation - we suggest using one of the other options above. +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. -No additional setup is required to use the Cache API. +Due to how the Cache API works, you need to be using a domain for your deployment for it to take effect. From 4c79adcdd3d8a9c30ba671fc5fb11083a9885ba3 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 16 Aug 2023 00:02:20 +0100 Subject: [PATCH 09/15] comment --- packages/next-on-pages/templates/_worker.js/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/next-on-pages/templates/_worker.js/index.ts b/packages/next-on-pages/templates/_worker.js/index.ts index bb500919a..ce047063a 100644 --- a/packages/next-on-pages/templates/_worker.js/index.ts +++ b/packages/next-on-pages/templates/_worker.js/index.ts @@ -28,6 +28,7 @@ export default { } return envAsyncLocalStorage.run( + // 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); From c7a5ac8bca675a4078c1ee8d751c35fc6bbe4696 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 16 Aug 2023 00:05:05 +0100 Subject: [PATCH 10/15] *smashes keyboard against head* --- packages/next-on-pages/env.d.ts | 3 --- packages/next-on-pages/templates/cache/cache-api.ts | 3 --- 2 files changed, 6 deletions(-) diff --git a/packages/next-on-pages/env.d.ts b/packages/next-on-pages/env.d.ts index 4d8f1b754..69ddf1a0f 100644 --- a/packages/next-on-pages/env.d.ts +++ b/packages/next-on-pages/env.d.ts @@ -5,9 +5,6 @@ declare global { npm_config_user_agent?: string; CF_PAGES?: string; SHELL?: string; - KV_SUSPENSE_CACHE?: KVNamespace; - D1_SUSPENSE_CACHE?: D1Database; - R2_SUSPENSE_CACHE?: R2Bucket; [key: string]: string | Fetcher; } } diff --git a/packages/next-on-pages/templates/cache/cache-api.ts b/packages/next-on-pages/templates/cache/cache-api.ts index 8c68e7243..2024fdb00 100644 --- a/packages/next-on-pages/templates/cache/cache-api.ts +++ b/packages/next-on-pages/templates/cache/cache-api.ts @@ -1,6 +1,5 @@ import type { IncrementalCacheValue } from './interface'; import { CacheInterface, SUSPENSE_CACHE_URL } from './interface'; -import { withMemoryInterfaceInDev } from './memory'; /** Suspense Cache interface for the Cache API. */ export class CacheApiInterface extends CacheInterface { @@ -43,5 +42,3 @@ export class CacheApiInterface extends CacheInterface { return `https://${SUSPENSE_CACHE_URL}/entry/${key}`; } } - -export default withMemoryInterfaceInDev; From c14a5ddc8987c15c2455b39899fa1157589f537b Mon Sep 17 00:00:00 2001 From: James Date: Fri, 18 Aug 2023 13:19:04 +0100 Subject: [PATCH 11/15] Update packages/next-on-pages/templates/cache/interface.ts Co-authored-by: Dario Piotrowicz --- packages/next-on-pages/templates/cache/interface.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/next-on-pages/templates/cache/interface.ts b/packages/next-on-pages/templates/cache/interface.ts index cc3bd45b1..0932acd50 100644 --- a/packages/next-on-pages/templates/cache/interface.ts +++ b/packages/next-on-pages/templates/cache/interface.ts @@ -132,9 +132,7 @@ export class CacheInterface { // noop } - if (!this.tagsManifest) { - this.tagsManifest = { version: 1, items: {} }; - } + this.tagsManifest ??= { version: 1, items: {} }; } /** From 0838006ba2da8bc587245946619d89cc719a4e64 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 18 Aug 2023 15:48:16 +0100 Subject: [PATCH 12/15] add `.local` to hostname --- packages/next-on-pages/templates/cache/interface.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/next-on-pages/templates/cache/interface.ts b/packages/next-on-pages/templates/cache/interface.ts index 0932acd50..c3b8d2bcb 100644 --- a/packages/next-on-pages/templates/cache/interface.ts +++ b/packages/next-on-pages/templates/cache/interface.ts @@ -1,4 +1,4 @@ -export const SUSPENSE_CACHE_URL = 'INTERNAL_SUSPENSE_CACHE_HOSTNAME'; +export const SUSPENSE_CACHE_URL = 'INTERNAL_SUSPENSE_CACHE_HOSTNAME.local'; // Set to track the revalidated tags in requests. const revalidatedTags = new Set(); From 6d21e3e3be582cb1236296e776ecf01baf738bb5 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 19 Aug 2023 11:33:25 +0100 Subject: [PATCH 13/15] apply suggestions + tweaks --- .../templates/_worker.js/utils/cache.ts | 17 ++++++------- .../cache/{interface.ts => adaptor.ts} | 5 ++-- .../templates/cache/cache-api.ts | 24 +++++++++---------- .../next-on-pages/templates/cache/index.ts | 2 +- 4 files changed, 24 insertions(+), 24 deletions(-) rename packages/next-on-pages/templates/cache/{interface.ts => adaptor.ts} (97%) diff --git a/packages/next-on-pages/templates/_worker.js/utils/cache.ts b/packages/next-on-pages/templates/_worker.js/utils/cache.ts index 87fb7625e..1d1aa6ce5 100644 --- a/packages/next-on-pages/templates/_worker.js/utils/cache.ts +++ b/packages/next-on-pages/templates/_worker.js/utils/cache.ts @@ -1,9 +1,10 @@ import { - CacheInterface, + CacheAdaptor, IncrementalCacheValue, SUSPENSE_CACHE_URL, } from '../../cache'; -import { CacheApiInterface } from '../../cache/cache-api'; +import { CacheApiAdaptor } from '../../cache/cache-api'; + /** * Handles an internal request to the suspense cache. * @@ -16,7 +17,7 @@ export async function handleSuspenseCacheRequest(request: Request) { try { const url = new URL(request.url); - const cache = await getSuspenseCacheInterface(); + const cache = await getSuspenseCacheAdaptor(); if (url.pathname === '/v1/suspense-cache/revalidate') { // Update the revalidated timestamp for the tags in the tags manifest. @@ -68,11 +69,11 @@ export async function handleSuspenseCacheRequest(request: Request) { } /** - * Gets the cache interface to use for the suspense cache. + * Gets the cache adaptor to use for the suspense cache. * - * @returns Interface for the suspense cache. + * @returns Adaptor for the suspense cache. */ -export async function getSuspenseCacheInterface(): Promise { - // TODO: Try to lazy import the custom cache interface. - return new CacheApiInterface(); +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/cache/interface.ts b/packages/next-on-pages/templates/cache/adaptor.ts similarity index 97% rename from packages/next-on-pages/templates/cache/interface.ts rename to packages/next-on-pages/templates/cache/adaptor.ts index c3b8d2bcb..acaa30193 100644 --- a/packages/next-on-pages/templates/cache/interface.ts +++ b/packages/next-on-pages/templates/cache/adaptor.ts @@ -1,10 +1,11 @@ +// 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 interface for the Suspense Cache. */ -export class CacheInterface { +/** 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. */ diff --git a/packages/next-on-pages/templates/cache/cache-api.ts b/packages/next-on-pages/templates/cache/cache-api.ts index 2024fdb00..b393aa669 100644 --- a/packages/next-on-pages/templates/cache/cache-api.ts +++ b/packages/next-on-pages/templates/cache/cache-api.ts @@ -1,28 +1,26 @@ -import type { IncrementalCacheValue } from './interface'; -import { CacheInterface, SUSPENSE_CACHE_URL } from './interface'; +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'; -/** Suspense Cache interface for the Cache API. */ -export class CacheApiInterface extends CacheInterface { constructor(ctx: Record = {}) { super(ctx); } public override async retrieve(key: string) { - const cache = await caches.open('suspense-cache'); + 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('suspense-cache'); - - // Figure out the max-age for the cache entry. - const entry = JSON.parse(value) as IncrementalCacheValue; - const maxAge = - key === this.tagsManifestKey || entry.kind !== 'FETCH' - ? '31536000' - : entry.revalidate; + 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({ diff --git a/packages/next-on-pages/templates/cache/index.ts b/packages/next-on-pages/templates/cache/index.ts index fc141f790..41c162740 100644 --- a/packages/next-on-pages/templates/cache/index.ts +++ b/packages/next-on-pages/templates/cache/index.ts @@ -1 +1 @@ -export * from './interface'; +export * from './adaptor'; From 155d35f7641849d14d98e41e474fe6752c8dbaba Mon Sep 17 00:00:00 2001 From: James Date: Mon, 21 Aug 2023 11:25:33 +0100 Subject: [PATCH 14/15] Apply suggestions from code review Co-authored-by: Dario Piotrowicz --- packages/next-on-pages/templates/_worker.js/utils/cache.ts | 5 +++-- packages/next-on-pages/templates/cache/adaptor.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/next-on-pages/templates/_worker.js/utils/cache.ts b/packages/next-on-pages/templates/_worker.js/utils/cache.ts index 1d1aa6ce5..7045c46b5 100644 --- a/packages/next-on-pages/templates/_worker.js/utils/cache.ts +++ b/packages/next-on-pages/templates/_worker.js/utils/cache.ts @@ -1,6 +1,7 @@ -import { +import type { CacheAdaptor, - IncrementalCacheValue, + IncrementalCacheValue} from '../../cache'; +import { SUSPENSE_CACHE_URL, } from '../../cache'; import { CacheApiAdaptor } from '../../cache/cache-api'; diff --git a/packages/next-on-pages/templates/cache/adaptor.ts b/packages/next-on-pages/templates/cache/adaptor.ts index acaa30193..e7af70a71 100644 --- a/packages/next-on-pages/templates/cache/adaptor.ts +++ b/packages/next-on-pages/templates/cache/adaptor.ts @@ -12,7 +12,7 @@ export class CacheAdaptor { public tagsManifestKey = 'tags-manifest'; /** - * @param ctx The incremental cache context. + * @param ctx The incremental cache context from Next.js. NOTE: This is not currently utilised in NOP. */ constructor(protected ctx: Record = {}) {} From 5edf60734921426ab80614c3034a5a0a26e71c0d Mon Sep 17 00:00:00 2001 From: James Date: Mon, 21 Aug 2023 11:38:31 +0100 Subject: [PATCH 15/15] dario broke prettier --- .../next-on-pages/templates/_worker.js/utils/cache.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/next-on-pages/templates/_worker.js/utils/cache.ts b/packages/next-on-pages/templates/_worker.js/utils/cache.ts index 7045c46b5..87432d743 100644 --- a/packages/next-on-pages/templates/_worker.js/utils/cache.ts +++ b/packages/next-on-pages/templates/_worker.js/utils/cache.ts @@ -1,9 +1,5 @@ -import type { - CacheAdaptor, - IncrementalCacheValue} from '../../cache'; -import { - SUSPENSE_CACHE_URL, -} from '../../cache'; +import type { CacheAdaptor, IncrementalCacheValue } from '../../cache'; +import { SUSPENSE_CACHE_URL } from '../../cache'; import { CacheApiAdaptor } from '../../cache/cache-api'; /**