Skip to content

Commit 53c14a4

Browse files
authored
feat: add FETCH cache (#46)
* feat: add FETCH cache Fixes https://linear.app/netlify/issue/FRA-60/implement-get-and-set * chore: close the connection * chore: update * chore: update * chore: increase lambda timeout * chore: fix tests * chore: cleanup * chore: cleanup and splitup for better performance * chore: update * chore: update * chore: build custom test sharding as the fetch test needs to run on it's own executor due to side effects of next.js patching the fetch function * chore: update
1 parent 0cb1530 commit 53c14a4

File tree

11 files changed

+294
-53
lines changed

11 files changed

+294
-53
lines changed

Diff for: .github/workflows/run-tests.yml

+7-2
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,21 @@ on:
55
jobs:
66
test:
77
runs-on: ubuntu-latest
8-
8+
strategy:
9+
matrix:
10+
shard: [1/3, 2/3, 3/3]
911
steps:
1012
- uses: actions/checkout@v4
1113
- name: 'Install Node'
1214
uses: actions/setup-node@v3
1315
with:
1416
node-version: '18.x'
17+
cache: 'npm'
18+
cache-dependency-path: '**/package-lock.json'
19+
1520
- name: 'Install dependencies'
1621
run: npm ci
1722
- name: 'build'
1823
run: npm run build
1924
- name: 'Test'
20-
run: npm run test:ci
25+
run: npm run test:ci -- --shard=${{ matrix.shard }}

Diff for: src/build/content/prerendered.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ const buildPrerenderedContentEntries = async (cwd: string): Promise<Promise<Cach
111111
if (isFetch(path)) {
112112
value = {
113113
kind: 'FETCH',
114-
data: JSON.parse(await readFile(`${cwd}/${key}`, 'utf-8')),
114+
...JSON.parse(await readFile(`${cwd}/${key}`, 'utf-8')),
115115
} satisfies FetchCacheValue
116116
}
117117

Diff for: src/run/handlers/cache.cts

+24-12
Original file line numberDiff line numberDiff line change
@@ -45,28 +45,35 @@ export default class NetlifyCacheHandler implements CacheHandler {
4545
console.debug(`[NetlifyCacheHandler.get]: ${cacheKey}`)
4646
const blob = await this.getBlobKey(cacheKey, ctx.fetchCache)
4747

48+
// if blob is null then we don't have a cache entry
4849
if (!blob) {
4950
return null
5051
}
5152

52-
const revalidateAfter = this.calculateRevalidate(cacheKey, blob.lastModified)
53+
const revalidateAfter = this.calculateRevalidate(cacheKey, blob.lastModified, ctx)
5354
// first check if there is a tag manifest
5455
// if not => stale check with revalidateAfter
5556
// yes => check with manifest
5657
const isStale = revalidateAfter !== false && revalidateAfter < Date.now()
57-
console.debug(`!!! CHACHE KEY: ${cacheKey} - is stale: `, {
58-
isStale,
59-
revalidateAfter,
60-
})
58+
console.debug(`!!! CHACHE KEY: ${cacheKey} - is stale: `, { isStale })
6159
if (isStale) {
6260
return null
6361
}
6462

6563
switch (blob.value.kind) {
66-
// TODO:
67-
// case 'FETCH':
64+
case 'FETCH':
65+
return {
66+
lastModified: blob.lastModified,
67+
value: {
68+
kind: blob.value.kind,
69+
data: blob.value.data,
70+
revalidate: ctx.revalidate || 1,
71+
},
72+
}
73+
6874
case 'ROUTE':
6975
return {
76+
lastModified: blob.lastModified,
7077
value: {
7178
body: Buffer.from(blob.value.body),
7279
kind: blob.value.kind,
@@ -79,11 +86,7 @@ export default class NetlifyCacheHandler implements CacheHandler {
7986
lastModified: blob.lastModified,
8087
value: blob.value,
8188
}
82-
83-
default:
84-
console.log('TODO: implement NetlifyCacheHandler.get', blob)
8589
}
86-
return null
8790
}
8891

8992
async set(...args: Parameters<IncrementalCache['set']>) {
@@ -192,11 +195,20 @@ export default class NetlifyCacheHandler implements CacheHandler {
192195
/**
193196
* Retrieves the milliseconds since midnight, January 1, 1970 when it should revalidate for a path.
194197
*/
195-
private calculateRevalidate(pathname: string, fromTime: number, dev?: boolean): number | false {
198+
private calculateRevalidate(
199+
pathname: string,
200+
fromTime: number,
201+
ctx: Parameters<CacheHandler['get']>[1],
202+
dev?: boolean,
203+
): number | false {
196204
// in development we don't have a prerender-manifest
197205
// and default to always revalidating to allow easier debugging
198206
if (dev) return Date.now() - 1_000
199207

208+
if (ctx?.revalidate && typeof ctx.revalidate === 'number') {
209+
return fromTime + ctx.revalidate * 1_000
210+
}
211+
200212
// if an entry isn't present in routes we fallback to a default
201213
const { initialRevalidateSeconds } = prerenderManifest.routes[toRoute(pathname)] || {
202214
initialRevalidateSeconds: 0,

Diff for: tests/fixtures/page-router/pages/static/revalidate.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export async function getStaticProps(context) {
1919
show: data,
2020
time: new Date().toISOString(),
2121
},
22-
revalidate: 2, // In seconds
22+
revalidate: 3, // In seconds
2323
}
2424
}
2525

Diff for: tests/fixtures/revalidate-fetch/app/posts/[id]/page.js

+5-4
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
const revalidateSeconds = 3
1+
const revalidateSeconds = +process.env.REVALIDATE_SECONDS || 3
2+
const API_BASE = process.env.API_BASE || 'https://api.tvmaze.com/shows/'
23

34
export async function generateStaticParams() {
45
return [{ id: '1' }, { id: '2' }]
56
}
67

78
async function getData(params) {
8-
const res = await fetch(`https://api.tvmaze.com/shows/${params.id}`, {
9+
const res = await fetch(new URL(params.id, API_BASE).href, {
910
next: { revalidate: revalidateSeconds },
1011
})
1112
return res.json()
@@ -21,9 +22,9 @@ export default async function Page({ params }) {
2122
<p>Revalidating every {revalidateSeconds} seconds</p>
2223
<dl>
2324
<dt>Show</dt>
24-
<dd>{data.name}</dd>
25+
<dd data-testid="name">{data.name}</dd>
2526
<dt>Param</dt>
26-
<dd>{params.id}</dd>
27+
<dd data-testid="id">{params.id}</dd>
2728
<dt>Time</dt>
2829
<dd data-testid="date-now">{Date.now()}</dd>
2930
</dl>

Diff for: tests/integration/cache-handler.test.ts

+12-32
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,7 @@ import {
88
runPlugin,
99
type FixtureTestContext,
1010
} from '../utils/fixture.js'
11-
import {
12-
createBlobContext,
13-
generateRandomObjectID,
14-
getBlobEntries,
15-
startMockBlobStore,
16-
} from '../utils/helpers.js'
11+
import { generateRandomObjectID, getBlobEntries, startMockBlobStore } from '../utils/helpers.js'
1712

1813
// Disable the verbose logging of the lambda-local runtime
1914
getLogger().level = 'alert'
@@ -23,6 +18,8 @@ beforeEach<FixtureTestContext>(async (ctx) => {
2318
ctx.deployID = generateRandomObjectID()
2419
ctx.siteID = v4()
2520
vi.stubEnv('DEPLOY_ID', ctx.deployID)
21+
// hide debug logs in tests
22+
// vi.spyOn(console, 'debug').mockImplementation(() => {})
2623

2724
await startMockBlobStore(ctx)
2825
})
@@ -48,12 +45,12 @@ describe('page router', () => {
4845
expect(call1.headers, 'a cache hit on the first invocation of a prerendered page').toEqual(
4946
expect.objectContaining({
5047
'x-nextjs-cache': 'HIT',
51-
'netlify-cdn-cache-control': 's-maxage=2, stale-while-revalidate',
48+
'netlify-cdn-cache-control': 's-maxage=3, stale-while-revalidate',
5249
}),
5350
)
5451

5552
// wait to have a stale page
56-
await new Promise<void>((resolve) => setTimeout(resolve, 1_000))
53+
await new Promise<void>((resolve) => setTimeout(resolve, 2_000))
5754

5855
// now it should be a cache miss
5956
const call2 = await invokeFunction(ctx, { url: 'static/revalidate' })
@@ -83,27 +80,6 @@ describe('page router', () => {
8380
})
8481

8582
describe('app router', () => {
86-
test<FixtureTestContext>('Test that the simple next app is working', async (ctx) => {
87-
await createFixture('simple-next-app', ctx)
88-
await runPlugin(ctx)
89-
// check if the blob entries where successful set on the build plugin
90-
const blobEntries = await getBlobEntries(ctx)
91-
expect(blobEntries).toEqual([
92-
{ key: 'server/app/_not-found', etag: expect.any(String) },
93-
{ key: 'server/app/index', etag: expect.any(String) },
94-
{ key: 'server/app/other', etag: expect.any(String) },
95-
])
96-
97-
// test the function call
98-
const home = await invokeFunction(ctx)
99-
expect(home.statusCode).toBe(200)
100-
expect(load(home.body)('h1').text()).toBe('Home')
101-
102-
const other = await invokeFunction(ctx, { url: 'other' })
103-
expect(other.statusCode).toBe(200)
104-
expect(load(other.body)('h1').text()).toBe('Other')
105-
})
106-
10783
test<FixtureTestContext>('should have a page prerendered, then wait for it to get stale and on demand revalidate it', async (ctx) => {
10884
await createFixture('revalidate-fetch', ctx)
10985
console.time('runPlugin')
@@ -179,7 +155,9 @@ describe('app router', () => {
179155
}),
180156
)
181157
})
158+
})
182159

160+
describe('plugin', () => {
183161
test<FixtureTestContext>('server-components blob store created correctly', async (ctx) => {
184162
await createFixture('server-components', ctx)
185163
await runPlugin(ctx)
@@ -202,7 +180,9 @@ describe('app router', () => {
202180
{ key: 'server/app/static-fetch-2', etag: expect.any(String) },
203181
])
204182
})
183+
})
205184

185+
describe('route', () => {
206186
test<FixtureTestContext>('route handler with revalidate', async (ctx) => {
207187
await createFixture('server-components', ctx)
208188
await runPlugin(ctx)
@@ -228,20 +208,20 @@ describe('app router', () => {
228208
}),
229209
)
230210
// wait to have a stale route
231-
await new Promise<void>((resolve) => setTimeout(resolve, 1_500))
211+
await new Promise<void>((resolve) => setTimeout(resolve, 2_000))
232212

233213
const call2 = await invokeFunction(ctx, { url: '/api/revalidate-handler' })
234214
const call2Body = JSON.parse(call2.body)
235215
const call2Time = call2Body.time
236216
expect(call2.statusCode).toBe(200)
237217
// it should have a new date rendered
238-
expect(call1Time, 'the date is a new one on a stale route').not.toBe(call2Time)
239218
expect(call2Body).toMatchObject({ data: expect.objectContaining({ id: 1 }) })
240219
expect(call2.headers, 'a cache miss on a stale route').toEqual(
241220
expect.objectContaining({
242221
'x-nextjs-cache': 'MISS',
243222
}),
244223
)
224+
expect(call1Time, 'the date is a new one on a stale route').not.toBe(call2Time)
245225

246226
// it does not wait for the cache.set so we have to manually wait here until the blob storage got populated
247227
await new Promise<void>((resolve) => setTimeout(resolve, 100))
@@ -250,12 +230,12 @@ describe('app router', () => {
250230
expect(call3.statusCode).toBe(200)
251231
const call3Body = JSON.parse(call3.body)
252232
const call3Time = call3Body.time
253-
expect(call3Time, 'the date was cached as well').toBe(call2Time)
254233
expect(call3Body).toMatchObject({ data: expect.objectContaining({ id: 1 }) })
255234
expect(call3.headers, 'a cache hit after dynamically regenerating the stale route').toEqual(
256235
expect.objectContaining({
257236
'x-nextjs-cache': 'HIT',
258237
}),
259238
)
239+
expect(call3Time, 'the date was cached as well').toBe(call2Time)
260240
})
261241
})

Diff for: tests/integration/fetch-handler.test.ts

+128
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { load } from 'cheerio'
2+
import getPort from 'get-port'
3+
import { getLogger } from 'lambda-local'
4+
import { createServer, type Server } from 'node:http'
5+
import { v4 } from 'uuid'
6+
import { afterAll, beforeAll, beforeEach, expect, test, vi } from 'vitest'
7+
import {
8+
createFixture,
9+
invokeFunction,
10+
runPlugin,
11+
type FixtureTestContext,
12+
} from '../utils/fixture.js'
13+
import {
14+
generateRandomObjectID,
15+
getBlobEntries,
16+
getFetchCacheKey,
17+
startMockBlobStore,
18+
} from '../utils/helpers.js'
19+
20+
// Disable the verbose logging of the lambda-local runtime
21+
getLogger().level = 'alert'
22+
23+
beforeEach<FixtureTestContext>(async (ctx) => {
24+
// set for each test a new deployID and siteID
25+
ctx.deployID = generateRandomObjectID()
26+
ctx.siteID = v4()
27+
vi.stubEnv('DEPLOY_ID', ctx.deployID)
28+
// hide debug logs in tests
29+
// vi.spyOn(console, 'debug').mockImplementation(() => {})
30+
31+
await startMockBlobStore(ctx)
32+
})
33+
34+
let apiBase: string
35+
let testServer: Server
36+
let handlerCalled = 0
37+
38+
beforeAll(async () => {
39+
// create a fake endpoint to test if it got called
40+
const port = await getPort({ host: '0.0.0.0' })
41+
42+
testServer = createServer((_, res) => {
43+
handlerCalled++
44+
res.writeHead(200, {
45+
'Content-Type': 'application/json',
46+
'cache-control': 'public, max-age=10000',
47+
})
48+
res.end(JSON.stringify({ id: '1', name: 'Fake response' }))
49+
})
50+
apiBase = await new Promise<string>((resolve) => {
51+
// we need always the same port so that the hash is the same
52+
testServer.listen(port, () => resolve(`http://0.0.0.0:${port}`))
53+
})
54+
})
55+
56+
afterAll(async () => {
57+
testServer.closeAllConnections()
58+
await new Promise((resolve) => {
59+
testServer.on('close', resolve)
60+
testServer.close()
61+
})
62+
})
63+
64+
test<FixtureTestContext>('if the fetch call is cached correctly', async (ctx) => {
65+
await createFixture('revalidate-fetch', ctx)
66+
console.time('TimeUntilStale')
67+
await runPlugin(ctx)
68+
69+
// replace the build time fetch cache with our mocked hash
70+
const cacheKey = await getFetchCacheKey(new URL('/1', apiBase).href)
71+
const originalKey =
72+
'cache/fetch-cache/460ed46cd9a194efa197be9f2571e51b729a039d1cff9834297f416dce5ada29'
73+
const fakeKey = `cache/fetch-cache/${cacheKey}`
74+
const fetchEntry = await ctx.blobStore.get(originalKey, { type: 'json' })
75+
76+
await Promise.all([
77+
// delete the page cache so that it falls back to the fetch call
78+
ctx.blobStore.delete('server/app/posts/1'),
79+
// delete the original key as we use the fake key only
80+
ctx.blobStore.delete(originalKey),
81+
ctx.blobStore.setJSON(fakeKey, fetchEntry),
82+
])
83+
84+
const blobEntries = await getBlobEntries(ctx)
85+
expect(blobEntries.map((e) => e.key).sort()).toEqual(
86+
[
87+
fakeKey,
88+
'cache/fetch-cache/ad74683e49684ff4fe3d01ba1bef627bc0e38b61fa6bd8244145fbaca87f3c49',
89+
'server/app/_not-found',
90+
'server/app/index',
91+
'server/app/posts/2',
92+
].sort(),
93+
)
94+
const post1 = await invokeFunction(ctx, {
95+
url: 'posts/1',
96+
env: {
97+
REVALIDATE_SECONDS: 10,
98+
API_BASE: apiBase,
99+
},
100+
})
101+
console.timeEnd('TimeUntilStale')
102+
103+
const post1Name = load(post1.body)('[data-testid="name"]').text()
104+
// should still get the old value
105+
expect(handlerCalled, 'should not call the API as the request should be cached').toBe(0)
106+
expect(post1.statusCode).toBe(200)
107+
expect(post1Name).toBe('Under the Dome')
108+
expect(post1.headers, 'the page should be a miss').toEqual(
109+
expect.objectContaining({
110+
'x-nextjs-cache': 'MISS',
111+
}),
112+
)
113+
114+
await new Promise<void>((resolve) => setTimeout(resolve, 10_000))
115+
// delete the generated page again to have a miss but go to the underlaying fetch call
116+
await ctx.blobStore.delete('server/app/posts/1')
117+
const post2 = await invokeFunction(ctx, {
118+
url: 'posts/1',
119+
env: {
120+
REVALIDATE_SECONDS: 10,
121+
API_BASE: apiBase,
122+
},
123+
})
124+
const post2Name = load(post2.body)('[data-testid="name"]').text()
125+
expect(post2.statusCode).toBe(200)
126+
expect.soft(post2Name).toBe('Fake response')
127+
expect(handlerCalled).toBe(1)
128+
})

0 commit comments

Comments
 (0)