Skip to content

Commit 9c2348a

Browse files
authored
feat: implement the set of PAGES inside the cache handler (#39)
* test: blob storage set and get * feat: add test and update the handler * chore: add some retry as it might get flaky through the stale thing
1 parent 0f0e3dd commit 9c2348a

File tree

15 files changed

+746
-57
lines changed

15 files changed

+746
-57
lines changed

package-lock.json

+7-7
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
"homepage": "https://github.com/netlify/next-runtime-minimal#readme",
3838
"dependencies": {
3939
"@fastly/http-compute-js": "1.1.1",
40-
"@netlify/blobs": "^3.3.0",
40+
"@netlify/blobs": "^4.0.0",
4141
"@netlify/build": "^29.20.6",
4242
"@netlify/functions": "^2.0.1",
4343
"@vercel/nft": "^0.24.3",

src/build/content/prerendered.ts

+8-6
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@ import pLimit from 'p-limit'
77
import { parse, ParsedPath } from 'path'
88
import { BUILD_DIR } from '../constants.js'
99

10-
type CacheEntry = {
10+
export type CacheEntry = {
1111
key: string
12-
value: {
13-
lastModified: number
14-
value: PageCacheValue | RouteCacheValue | FetchCacheValue
15-
}
12+
value: CacheEntryValue
13+
}
14+
15+
export type CacheEntryValue = {
16+
lastModified: number
17+
value: PageCacheValue | RouteCacheValue | FetchCacheValue
1618
}
1719

1820
type PageCacheValue = {
@@ -142,7 +144,7 @@ export const uploadPrerenderedContent = async ({
142144

143145
// read prerendered content and build JSON key/values for the blob store
144146
const entries = await Promise.allSettled(
145-
await buildPrerenderedContentEntries(`${BUILD_DIR}/.next/standalone/.next`),
147+
await buildPrerenderedContentEntries(`${BUILD_DIR}/.next`),
146148
)
147149
entries.forEach((result) => {
148150
if (result.status === 'rejected') {

src/build/content/static.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ const copyStaticPages = async (src: string, dest: string): Promise<Promise<void>
3030
export const copyStaticContent = async ({ PUBLISH_DIR }: NetlifyPluginConstants): Promise<void> => {
3131
await Promise.all([
3232
// static pages
33-
Promise.all(await copyStaticPages(`${BUILD_DIR}/.next/standalone/.next`, PUBLISH_DIR)),
33+
Promise.all(await copyStaticPages(`${BUILD_DIR}/.next`, PUBLISH_DIR)),
3434
// static assets
3535
copy(`${BUILD_DIR}/.next/static/`, `${PUBLISH_DIR}/_next/static`),
3636
// public assets

src/run/handlers/cache.cts

+96-26
Original file line numberDiff line numberDiff line change
@@ -1,71 +1,141 @@
1+
// Netlify Cache Handler
2+
// (CJS format because Next.js doesn't support ESM yet)
3+
//
14
import { getDeployStore } from '@netlify/blobs'
25
import { purgeCache } from '@netlify/functions'
36
import type {
47
CacheHandler,
58
CacheHandlerContext,
9+
IncrementalCache,
610
} from 'next/dist/server/lib/incremental-cache/index.js'
11+
import { join } from 'node:path/posix'
12+
// @ts-expect-error This is a type only import
13+
import type { CacheEntryValue } from '../../build/content/prerendered.js'
714

815
type TagManifest = { revalidatedAt: number }
916

1017
const tagsManifestPath = '_netlify-cache/tags'
1118
const blobStore = getDeployStore()
1219

13-
/**
14-
* Netlify Cache Handler
15-
* (CJS format because Next.js doesn't support ESM yet)
16-
*/
1720
export default class NetlifyCacheHandler implements CacheHandler {
1821
options: CacheHandlerContext
1922
revalidatedTags: string[]
23+
/** Indicates if the application is using the new appDir */
24+
#appDir: boolean
2025

2126
constructor(options: CacheHandlerContext) {
27+
this.#appDir = Boolean(options._appDir)
2228
this.options = options
2329
this.revalidatedTags = options.revalidatedTags
2430
}
2531

26-
// eslint-disable-next-line require-await, class-methods-use-this
27-
public async get(key: string, ctx: any) {
28-
console.log('NetlifyCacheHandler.get', key, JSON.stringify(ctx, null, 2))
32+
async get(...args: Parameters<CacheHandler['get']>): ReturnType<CacheHandler['get']> {
33+
const [cacheKey, ctx = {}] = args
34+
console.log(`[NetlifyCacheHandler.get]: ${cacheKey}`)
35+
const blob = await this.getBlobKey(cacheKey, ctx.fetchCache)
36+
37+
switch (blob?.value?.kind) {
38+
// TODO:
39+
// case 'ROUTE':
40+
// case 'FETCH':
41+
case 'PAGE':
42+
// TODO: determine if the page is stale based on the blob.lastModified Date.now()
43+
return {
44+
lastModified: blob.lastModified,
45+
value: {
46+
kind: 'PAGE',
47+
html: blob.value.html,
48+
pageData: blob.value.pageData,
49+
headers: blob.value.headers,
50+
status: blob.value.status,
51+
},
52+
}
53+
54+
default:
55+
console.log('TODO: implmenet', blob)
56+
}
2957
return null
3058
}
3159

3260
// eslint-disable-next-line require-await, class-methods-use-this
33-
public async set(key: string, data: any, ctx: any) {
34-
console.log(
35-
'NetlifyCacheHandler.set',
36-
key,
37-
JSON.stringify(data, null, 2),
38-
JSON.stringify(ctx, null, 2),
39-
)
61+
async set(...args: Parameters<IncrementalCache['set']>) {
62+
const [key, data, ctx] = args
63+
console.log('NetlifyCacheHandler.set', key)
4064
}
4165

42-
// eslint-disable-next-line require-await
43-
public async revalidateTag(tag: string) {
66+
async revalidateTag(tag: string) {
4467
console.log('NetlifyCacheHandler.revalidateTag', tag)
4568

4669
const data: TagManifest = {
4770
revalidatedAt: Date.now(),
4871
}
4972

5073
try {
51-
blobStore.setJSON(this.tagManifestPath(tag), data)
52-
} catch (error: any) {
74+
await blobStore.setJSON(this.tagManifestPath(tag), data)
75+
} catch (error) {
5376
console.warn(`Failed to update tag manifest for ${tag}`, error)
5477
}
5578

5679
purgeCache({ tags: [tag] })
5780
}
5881

59-
private async loadTagManifest(tag: string) {
60-
try {
61-
return await blobStore.get(this.tagManifestPath(tag), { type: 'json' })
62-
} catch (error: any) {
63-
console.warn(`Failed to fetch tag manifest for ${tag}`, error)
64-
}
65-
}
66-
6782
// eslint-disable-next-line class-methods-use-this
6883
private tagManifestPath(tag: string) {
6984
return [tagsManifestPath, tag].join('/')
7085
}
86+
87+
/**
88+
* Computes a cache key and tries to load it for different scenarios (app/server or fetch)
89+
* @param key The cache key used by next.js
90+
* @param fetch If it is a FETCH request or not
91+
* @returns the parsed data from the cache or null if not
92+
*/
93+
private async getBlobKey(
94+
key: string,
95+
fetch?: boolean,
96+
): Promise<
97+
| null
98+
| ({
99+
path: string
100+
isAppPath: boolean
101+
} & CacheEntryValue)
102+
> {
103+
const appKey = join('server/app', key)
104+
const pagesKey = join('server/pages', key)
105+
const fetchKey = join('cache/fetch-cache', key)
106+
107+
if (fetch) {
108+
return await blobStore
109+
.get(fetchKey, { type: 'json' })
110+
.then((res) => (res !== null ? { path: fetchKey, isAppPath: false, ...res } : null))
111+
}
112+
113+
// pagesKey needs to be requested first as there could be both sadly
114+
const values = await Promise.all([
115+
blobStore
116+
.get(pagesKey, { type: 'json' })
117+
.then((res) => ({ path: pagesKey, isAppPath: false, ...res })),
118+
// only request the appKey if the whole application supports the app key
119+
!this.#appDir
120+
? Promise.resolve(null)
121+
: blobStore
122+
.get(appKey, { type: 'json' })
123+
.then((res) => ({ path: appKey, isAppPath: true, ...res })),
124+
])
125+
126+
// just get the first item out of it that is defined (either the pageRoute or the appRoute)
127+
const [cacheEntry] = values.filter(({ value }) => !!value)
128+
129+
// TODO: set the cache tags based on the tag manifest once we have that
130+
// if (cacheEntry) {
131+
// const cacheTags: string[] =
132+
// cacheEntry.value.headers?.[NEXT_CACHE_TAGS_HEADER]?.split(',') || []
133+
// const manifests = await Promise.all(
134+
// cacheTags.map((tag) => blobStore.get(this.tagManifestPath(tag), { type: 'json' })),
135+
// )
136+
// console.log(manifests)
137+
// }
138+
139+
return cacheEntry || null
140+
}
71141
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export const metadata = {
2+
title: 'Revalidate fetch',
3+
description: 'Description for Simple Next App',
4+
}
5+
6+
export default function RootLayout({ children }) {
7+
return (
8+
<html lang="en">
9+
<body>{children}</body>
10+
</html>
11+
)
12+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export default function Home() {
2+
return (
3+
<main>
4+
<h1>Home</h1>
5+
</main>
6+
)
7+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
const revalidateSeconds = 3
2+
3+
export async function generateStaticParams() {
4+
return [{ id: '1' }, { id: '2' }]
5+
}
6+
7+
async function getData(params) {
8+
const res = await fetch(`https://api.tvmaze.com/shows/${params.id}`, {
9+
next: { revalidate: revalidateSeconds },
10+
})
11+
return res.json()
12+
}
13+
14+
export default async function Page({ params }) {
15+
const data = await getData(params)
16+
17+
return (
18+
<>
19+
<h1>Revalidate Fetch</h1>
20+
<p>Paths /1 and /2 prerendered; other paths rendered on-demand</p>
21+
<p>Revalidating every {revalidateSeconds} seconds</p>
22+
<dl>
23+
<dt>Show</dt>
24+
<dd>{data.name}</dd>
25+
<dt>Param</dt>
26+
<dd>{params.id}</dd>
27+
<dt>Time</dt>
28+
<dd data-testid="date-now">{Date.now()}</dd>
29+
</dl>
30+
</>
31+
)
32+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/** @type {import('next').NextConfig} */
2+
const nextConfig = {
3+
output: 'standalone',
4+
eslint: {
5+
ignoreDuringBuilds: true,
6+
},
7+
}
8+
9+
module.exports = nextConfig

0 commit comments

Comments
 (0)