Skip to content

Commit 85aac60

Browse files
authored
feat: add on demand revalidation by tags and path (#53)
* feat: add on demand revalidation by tags and path * chore: fix tests
1 parent 0ff1d1d commit 85aac60

File tree

13 files changed

+1745
-112
lines changed

13 files changed

+1745
-112
lines changed

package-lock.json

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

src/run/handlers/cache.cts

+50-8
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
CacheHandlerContext,
1010
IncrementalCache,
1111
} from 'next/dist/server/lib/incremental-cache/index.js'
12+
import { NEXT_CACHE_TAGS_HEADER } from 'next/dist/lib/constants.js'
1213
import { join } from 'node:path/posix'
1314
// @ts-expect-error This is a type only import
1415
import type { CacheEntryValue } from '../../build/content/prerendered.js'
@@ -51,12 +52,11 @@ export default class NetlifyCacheHandler implements CacheHandler {
5152
}
5253

5354
const revalidateAfter = this.calculateRevalidate(cacheKey, blob.lastModified, ctx)
54-
// first check if there is a tag manifest
55-
// if not => stale check with revalidateAfter
56-
// yes => check with manifest
5755
const isStale = revalidateAfter !== false && revalidateAfter < Date.now()
58-
console.debug(`!!! CHACHE KEY: ${cacheKey} - is stale: `, { isStale })
59-
if (isStale) {
56+
const staleByTags = await this.checkCacheEntryStaleByTags(blob, ctx.softTags)
57+
console.debug(`!!! CHACHE KEY: ${cacheKey} - is stale: `, { isStale, staleByTags })
58+
59+
if (staleByTags || isStale) {
6060
return null
6161
}
6262

@@ -116,20 +116,23 @@ export default class NetlifyCacheHandler implements CacheHandler {
116116
}
117117
}
118118

119-
async revalidateTag(tag: string) {
120-
console.debug('NetlifyCacheHandler.revalidateTag', tag)
119+
async revalidateTag(tag: string, ...args: any) {
120+
console.debug('NetlifyCacheHandler.revalidateTag', tag, args)
121121

122122
const data: TagManifest = {
123123
revalidatedAt: Date.now(),
124124
}
125125

126126
try {
127+
console.log('set cache tag for ', { tag: this.tagManifestPath(tag), data })
127128
await blobStore.setJSON(this.tagManifestPath(tag), data)
128129
} catch (error) {
129130
console.warn(`Failed to update tag manifest for ${tag}`, error)
130131
}
131132

132-
purgeCache({ tags: [tag] })
133+
purgeCache({ tags: [tag] }).catch(() => {
134+
// noop
135+
})
133136
}
134137

135138
// eslint-disable-next-line class-methods-use-this
@@ -192,6 +195,45 @@ export default class NetlifyCacheHandler implements CacheHandler {
192195
return cacheEntry || null
193196
}
194197

198+
/**
199+
* Checks if a page is stale through on demand revalidated tags
200+
*/
201+
private async checkCacheEntryStaleByTags(cacheEntry: CacheEntryValue, softTags: string[] = []) {
202+
const tags =
203+
'headers' in cacheEntry.value
204+
? cacheEntry.value.headers?.[NEXT_CACHE_TAGS_HEADER]?.split(',') || []
205+
: []
206+
207+
const cacheTags = [...tags, ...softTags]
208+
const allManifests = await Promise.all(
209+
cacheTags.map(async (tag) => {
210+
const key = this.tagManifestPath(tag)
211+
const res = await blobStore
212+
.get(key, { type: 'json' })
213+
.then((value) => ({ [key]: value }))
214+
.catch(console.error)
215+
return res || { [key]: null }
216+
}),
217+
)
218+
219+
const tagsManifest = Object.assign({}, ...allManifests) as Record<
220+
string,
221+
null | { revalidatedAt: number }
222+
>
223+
224+
const isStale = cacheTags.some((tag) => {
225+
// TODO: test for this case
226+
if (this.revalidatedTags.includes(tag)) {
227+
return true
228+
}
229+
230+
const { revalidatedAt } = tagsManifest[this.tagManifestPath(tag)] || {}
231+
return revalidatedAt && revalidatedAt >= (cacheEntry.lastModified || Date.now())
232+
})
233+
234+
return isStale
235+
}
236+
195237
/**
196238
* Retrieves the milliseconds since midnight, January 1, 1970 when it should revalidate for a path.
197239
*/
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { NextRequest, NextResponse } from 'next/server'
2+
import { revalidatePath } from 'next/cache'
3+
4+
export async function GET(request: NextRequest) {
5+
revalidatePath('/static-fetch/[id]', 'page')
6+
return NextResponse.json({ revalidated: true, now: new Date().toISOString() })
7+
}
8+
9+
export const dynamic = 'force-dynamic'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { NextRequest, NextResponse } from 'next/server'
2+
import { revalidateTag } from 'next/cache'
3+
4+
export async function GET(request: NextRequest) {
5+
revalidateTag('collection')
6+
return NextResponse.json({ revalidated: true, now: new Date().toISOString() })
7+
}
8+
9+
export const dynamic = 'force-dynamic'

tests/fixtures/server-components/app/static-fetch-1/page.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@ export default async function Page() {
1010

1111
return (
1212
<>
13-
<h1>Hello, Statically Rendered Server Component</h1>
13+
<h1>Hello, Static Fetch 1</h1>
1414
<dl>
1515
<dt>Quote</dt>
1616
<dd>{data[0].quote}</dd>
1717
<dt>Time</dt>
18-
<dd>{Date.now()}</dd>
18+
<dd data-testid="date-now">{new Date().toISOString()}</dd>
1919
</dl>
2020
</>
2121
)

tests/fixtures/server-components/app/static-fetch-2/page.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@ export default async function Page() {
1010

1111
return (
1212
<>
13-
<h1>Hello, Statically Rendered Server Component (same as static-fetch-1)</h1>
13+
<h1>Hello, Static Fetch 2</h1>
1414
<dl>
1515
<dt>Quote</dt>
1616
<dd>{data[0].quote}</dd>
1717
<dt>Time</dt>
18-
<dd>{Date.now()}</dd>
18+
<dd data-testid="date-now">{new Date().toISOString()}</dd>
1919
</dl>
2020
</>
2121
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
async function getData() {
2+
const res = await fetch(`https://strangerthings-quotes.vercel.app/api/quotes`, {
3+
next: { tags: ['collection'] },
4+
})
5+
return res.json()
6+
}
7+
8+
export default async function Page() {
9+
const data = await getData()
10+
11+
return (
12+
<>
13+
<h1>Hello, Static Fetch 3</h1>
14+
<dl>
15+
<dt>Quote</dt>
16+
<dd>{data[0].quote}</dd>
17+
<dt>Time</dt>
18+
<dd data-testid="date-now">{new Date().toISOString()}</dd>
19+
</dl>
20+
</>
21+
)
22+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
export async function generateStaticParams() {
2+
return [{ id: '1' }, { id: '2' }]
3+
}
4+
5+
async function getData(params) {
6+
const res = await fetch(`https://api.tvmaze.com/shows/${params.id}`)
7+
return res.json()
8+
}
9+
10+
export default async function Page({ params }) {
11+
const data = await getData(params)
12+
13+
return (
14+
<>
15+
<h1>Hello, Statically fetched show {data.id}</h1>
16+
<p>Paths /1 and /2 prerendered; other paths not found</p>
17+
<dl>
18+
<dt>Show</dt>
19+
<dd>{data.name}</dd>
20+
<dt>Param</dt>
21+
<dd>{params.id}</dd>
22+
<dt>Time</dt>
23+
<dd data-testid="date-now">{new Date().toISOString()}</dd>
24+
</dl>
25+
</>
26+
)
27+
}

tests/integration/cache-handler.test.ts

+15-15
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ beforeEach<FixtureTestContext>(async (ctx) => {
1717
// set for each test a new deployID and siteID
1818
ctx.deployID = generateRandomObjectID()
1919
ctx.siteID = v4()
20+
vi.stubEnv('SITE_ID', ctx.siteID)
2021
vi.stubEnv('DEPLOY_ID', ctx.deployID)
22+
vi.stubEnv('NETLIFY_PURGE_API_TOKEN', 'fake-token')
2123
// hide debug logs in tests
2224
// vi.spyOn(console, 'debug').mockImplementation(() => {})
2325

@@ -163,21 +165,19 @@ describe('plugin', () => {
163165
await runPlugin(ctx)
164166
// check if the blob entries where successful set on the build plugin
165167
const blobEntries = await getBlobEntries(ctx)
166-
expect(blobEntries).toEqual([
167-
{
168-
key: 'cache/fetch-cache/460ed46cd9a194efa197be9f2571e51b729a039d1cff9834297f416dce5ada29',
169-
etag: expect.any(String),
170-
},
171-
{
172-
key: 'cache/fetch-cache/ac26c54e17c3018c17bfe5ae6adc0e6d37dbfaf28445c1f767ff267144264ac9',
173-
etag: expect.any(String),
174-
},
175-
{ key: 'server/app/_not-found', etag: expect.any(String) },
176-
{ key: 'server/app/api/revalidate-handler', etag: expect.any(String) },
177-
{ key: 'server/app/index', etag: expect.any(String) },
178-
{ key: 'server/app/revalidate-fetch', etag: expect.any(String) },
179-
{ key: 'server/app/static-fetch-1', etag: expect.any(String) },
180-
{ key: 'server/app/static-fetch-2', etag: expect.any(String) },
168+
expect(blobEntries.map((entry) => entry.key)).toEqual([
169+
'cache/fetch-cache/460ed46cd9a194efa197be9f2571e51b729a039d1cff9834297f416dce5ada29',
170+
'cache/fetch-cache/ac26c54e17c3018c17bfe5ae6adc0e6d37dbfaf28445c1f767ff267144264ac9',
171+
'cache/fetch-cache/ad74683e49684ff4fe3d01ba1bef627bc0e38b61fa6bd8244145fbaca87f3c49',
172+
'server/app/_not-found',
173+
'server/app/api/revalidate-handler',
174+
'server/app/index',
175+
'server/app/revalidate-fetch',
176+
'server/app/static-fetch/1',
177+
'server/app/static-fetch/2',
178+
'server/app/static-fetch-1',
179+
'server/app/static-fetch-2',
180+
'server/app/static-fetch-3',
181181
])
182182
})
183183
})

tests/integration/fetch-handler.test.ts

+1
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ beforeEach<FixtureTestContext>(async (ctx) => {
2424
// set for each test a new deployID and siteID
2525
ctx.deployID = generateRandomObjectID()
2626
ctx.siteID = v4()
27+
vi.stubEnv('SITE_ID', ctx.siteID)
2728
vi.stubEnv('DEPLOY_ID', ctx.deployID)
2829
// hide debug logs in tests
2930
// vi.spyOn(console, 'debug').mockImplementation(() => {})
+90
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { load } from 'cheerio'
2+
import { getLogger } from 'lambda-local'
3+
import { v4 } from 'uuid'
4+
import { beforeEach, expect, test, vi } from 'vitest'
5+
import {
6+
createFixture,
7+
invokeFunction,
8+
runPlugin,
9+
type FixtureTestContext,
10+
} from '../utils/fixture.js'
11+
import { generateRandomObjectID, getBlobEntries, startMockBlobStore } from '../utils/helpers.js'
12+
13+
// Disable the verbose logging of the lambda-local runtime
14+
getLogger().level = 'alert'
15+
16+
beforeEach<FixtureTestContext>(async (ctx) => {
17+
// set for each test a new deployID and siteID
18+
ctx.deployID = generateRandomObjectID()
19+
ctx.siteID = v4()
20+
vi.stubEnv('SITE_ID', ctx.siteID)
21+
vi.stubEnv('DEPLOY_ID', ctx.deployID)
22+
vi.stubEnv('NETLIFY_PURGE_API_TOKEN', 'fake-token')
23+
// hide debug logs in tests
24+
// vi.spyOn(console, 'debug').mockImplementation(() => {})
25+
26+
await startMockBlobStore(ctx)
27+
})
28+
29+
test<FixtureTestContext>('should revalidate a route by path', async (ctx) => {
30+
await createFixture('server-components', ctx)
31+
await runPlugin(ctx)
32+
33+
expect(await ctx.blobStore.get('server/app/static-fetch/1')).not.toBeNull()
34+
expect(await ctx.blobStore.get('.netlfiy/cache/tags/_N_T_/static-fetch/[id]/page')).toBeNull()
35+
36+
// test the function call
37+
const [post1, post1Route2] = await Promise.all([
38+
invokeFunction(ctx, { url: '/static-fetch/1' }),
39+
invokeFunction(ctx, { url: '/static-fetch/2' }),
40+
])
41+
const post1Date = load(post1.body)('[data-testid="date-now"]').text()
42+
expect(post1.statusCode).toBe(200)
43+
expect(post1Route2.statusCode).toBe(200)
44+
expect(load(post1.body)('h1').text()).toBe('Hello, Statically fetched show 1')
45+
expect(post1.headers, 'a cache hit on the first invocation of a prerendered page').toEqual(
46+
expect.objectContaining({
47+
'x-nextjs-cache': 'HIT',
48+
'netlify-cdn-cache-control': 's-maxage=31536000, stale-while-revalidate',
49+
}),
50+
)
51+
expect(post1Route2.headers, 'a cache hit on the first invocation of a prerendered page').toEqual(
52+
expect.objectContaining({
53+
'x-nextjs-cache': 'HIT',
54+
'netlify-cdn-cache-control': 's-maxage=31536000, stale-while-revalidate',
55+
}),
56+
)
57+
58+
const revalidate = await invokeFunction(ctx, { url: '/api/on-demand-revalidate/path' })
59+
expect(revalidate.statusCode).toBe(200)
60+
expect(JSON.parse(revalidate.body)).toEqual({ revalidated: true, now: expect.any(String) })
61+
// expect(calledPurge).toBe(1)
62+
// it does not wait for the cache.set so we have to manually wait here until the blob storage got populated
63+
await new Promise<void>((resolve) => setTimeout(resolve, 1000))
64+
65+
const entries = await getBlobEntries(ctx)
66+
expect(await ctx.blobStore.get('.netlfiy/cache/tags/_N_T_/static-fetch/[id]/page')).not.toBeNull()
67+
68+
console.log(entries)
69+
const [post2, post2Route2] = await Promise.all([
70+
invokeFunction(ctx, { url: '/static-fetch/1' }),
71+
invokeFunction(ctx, { url: '/static-fetch/2' }),
72+
])
73+
const post2Date = load(post2.body)('[data-testid="date-now"]').text()
74+
expect(post2.statusCode).toBe(200)
75+
expect(post2Route2.statusCode).toBe(200)
76+
expect(load(post2.body)('h1').text()).toBe('Hello, Statically fetched show 1')
77+
expect(post2.headers, 'a cache miss on the on demand revalidated path /1').toEqual(
78+
expect.objectContaining({
79+
'x-nextjs-cache': 'MISS',
80+
'netlify-cdn-cache-control': 's-maxage=31536000, stale-while-revalidate',
81+
}),
82+
)
83+
expect(post2Route2.headers, 'a cache miss on the on demand revalidated path /2').toEqual(
84+
expect.objectContaining({
85+
'x-nextjs-cache': 'MISS',
86+
'netlify-cdn-cache-control': 's-maxage=31536000, stale-while-revalidate',
87+
}),
88+
)
89+
expect(post2Date).not.toBe(post1Date)
90+
})

0 commit comments

Comments
 (0)