Skip to content

Commit

Permalink
feat: fetch (suspense) cache handling, and next/cache support (#419)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>

* 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 <[email protected]>

* add `.local` to hostname

* apply suggestions + tweaks

* Apply suggestions from code review

Co-authored-by: Dario Piotrowicz <[email protected]>

* dario broke prettier

---------

Co-authored-by: Dario Piotrowicz <[email protected]>
  • Loading branch information
james-elicx and dario-piotrowicz authored Aug 21, 2023
1 parent 4570aa6 commit 291bfde
Show file tree
Hide file tree
Showing 11 changed files with 407 additions and 16 deletions.
5 changes: 5 additions & 0 deletions .changeset/few-icons-shop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@cloudflare/next-on-pages': patch
---

Stop the `cache` property in fetch requests causing internal server error.
5 changes: 5 additions & 0 deletions .changeset/forty-seas-hug.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@cloudflare/next-on-pages': minor
---

Support for the internal fetch (suspense) cache, and `next/cache` data revalidation.
15 changes: 15 additions & 0 deletions packages/next-on-pages/docs/caching.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 1 addition & 11 deletions packages/next-on-pages/docs/supported.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
5 changes: 4 additions & 1 deletion packages/next-on-pages/templates/_worker.js/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { SUSPENSE_CACHE_URL } from '../cache';
import { handleRequest } from './handleRequest';
import {
adjustRequestForVercel,
Expand Down Expand Up @@ -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')) {
Expand Down
76 changes: 76 additions & 0 deletions packages/next-on-pages/templates/_worker.js/utils/cache.ts
Original file line number Diff line number Diff line change
@@ -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<IncrementalCacheValue>();
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<CacheAdaptor> {
// TODO: Try to lazy import the custom cache adaptor.
return new CacheApiAdaptor();
}
11 changes: 7 additions & 4 deletions packages/next-on-pages/templates/_worker.js/utils/fetch.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { handleSuspenseCacheRequest } from './cache';

/**
* Patches the global fetch in ways necessary for Next.js (/next-on-pages) applications
* to work
Expand All @@ -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);

Expand Down
Loading

0 comments on commit 291bfde

Please sign in to comment.