Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: fetch (suspense) cache handling, and next/cache support #419

Merged
merged 15 commits into from
Aug 21, 2023
Merged
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.
dario-piotrowicz marked this conversation as resolved.
Show resolved Hide resolved
// 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 },
james-elicx marked this conversation as resolved.
Show resolved Hide resolved
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