Skip to content

Commit d2eeda9

Browse files
Skn0ttascorbicpieh
authoredFeb 6, 2024
fix: let next-server handle SWR behavior (#206)
* chore: add local repro of failing test * fix: return stale values for cache entries * fix: give blob a couple seconds to propagate back * fix: ensure lambda doesn't die before the revalidate is done * chore: turn test page into js to prevent it from installing typescript * chore: remove .only * fix: also adjust date header for stale cache serves * fix: set cache-status header for SWR * chore: update route handler test to capture SWR * similar fixes for page router with static revalidate * aaand another test * fix: another test * remove .only * fix first test * fix: route handlers are also cacheable! * test: cache-handler test for page router updated for SWR behavior * chore: remove no longer used CacheHandler helper method * chore remove unused imports and variables used in previously removed method * test: cache-handler test for route handler updated for SWR behavior * test: invoke functions in integration tests in sandboxed child process * test: move request mocking to lambda runner and mock just purge API * Revert "test: move request mocking to lambda runner and mock just purge API" This reverts commit c773508bd1fc1398711fe242e791123642cc5688. * Revert "test: invoke functions in integration tests in sandboxed child process" This reverts commit a834f622b3fd6829032c53654d3600ceeb9492e3. * chore: update msw/node setup to get rid of bunch of warnings * test: split cache-handler tests into 3 separate test suites, one for each fixture * adjust sequencer to run all 3 cache-handler tests in same shard * adjust tests * upgrade vitest * run test suites sequentially * maybe fix lock file * add delay to app router cachehandler tests * test: update fetch-handdler integration tests * Revert "add delay to app router cachehandler tests" This reverts commit b4112c4a53bf2d34086ca349944dfe3a588abbdd. * Revert "maybe fix lock file" This reverts commit db186688fd3f035176176fd76b4b7710ec46436b. * Revert "run test suites sequentially" This reverts commit 548f81d222eb5729e963a494d6a5ebf09dae68e9. * Revert "upgrade vitest" This reverts commit d65ed1a64f16dca2e328fd2fc2d9b79b9ae48448. * Revert "adjust tests" This reverts commit bc620eee8455336e98feeaea83dda5dbe250ddaf. * Revert "adjust sequencer to run all 3 cache-handler tests in same shard" This reverts commit 23f65d3797c69bff58fa24fd6b3ffd9430a31099. * Revert "test: split cache-handler tests into 3 separate test suites, one for each fixture" This reverts commit 6cb1422e5883964a7cf4bfd04b76fb0dbbb902d9. * test: readd delay after reverting setup changes * test: add some debug logs * Revert "test: add some debug logs" This reverts commit 35e92a5b3b0198de9a951aa97f7543839a72a62c. * fix: fix bad merge conflict resolution --------- Co-authored-by: Matt Kane <matt.kane@netlify.com> Co-authored-by: Michal Piechowiak <misiek.piechowiak@gmail.com>
1 parent 8b17f09 commit d2eeda9

File tree

11 files changed

+497
-163
lines changed

11 files changed

+497
-163
lines changed
 

‎src/run/handlers/cache.cts

+2-47
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,10 @@
22
// (CJS format because Next.js doesn't support ESM yet)
33
//
44
import { Buffer } from 'node:buffer'
5-
import { readFileSync } from 'node:fs'
6-
import { join } from 'node:path/posix'
75

86
import { getDeployStore, Store } from '@netlify/blobs'
97
import { purgeCache } from '@netlify/functions'
108
import { trace } from '@opentelemetry/api'
11-
import type { PrerenderManifest } from 'next/dist/build/index.js'
129
import { NEXT_CACHE_TAGS_HEADER } from 'next/dist/lib/constants.js'
1310
import type {
1411
CacheHandler,
@@ -18,16 +15,6 @@ import type {
1815

1916
type TagManifest = { revalidatedAt: number }
2017

21-
// load the prerender manifest
22-
const prerenderManifest: PrerenderManifest = JSON.parse(
23-
readFileSync(join(process.cwd(), '.next/prerender-manifest.json'), 'utf-8'),
24-
)
25-
26-
/** Strips trailing slashes and normalizes the index root */
27-
function toRoute(cacheKey: string): string {
28-
return cacheKey.replace(/\/$/, '').replace(/\/index$/, '') || '/'
29-
}
30-
3118
const fetchBeforeNextPatchedIt = globalThis.fetch
3219

3320
export class NetlifyCacheHandler implements CacheHandler {
@@ -65,12 +52,10 @@ export class NetlifyCacheHandler implements CacheHandler {
6552
return null
6653
}
6754

68-
const revalidateAfter = this.calculateRevalidate(key, blob.lastModified, ctx)
69-
const isStale = revalidateAfter !== false && revalidateAfter < Date.now()
7055
const staleByTags = await this.checkCacheEntryStaleByTags(blob, ctx.softTags)
7156

72-
if (staleByTags || isStale) {
73-
span.addEvent('Stale', { staleByTags, isStale })
57+
if (staleByTags) {
58+
span.addEvent('Stale', { staleByTags })
7459
span.end()
7560
return null
7661
}
@@ -194,36 +179,6 @@ export class NetlifyCacheHandler implements CacheHandler {
194179

195180
return isStale
196181
}
197-
198-
/**
199-
* Retrieves the milliseconds since midnight, January 1, 1970 when it should revalidate for a path.
200-
*/
201-
private calculateRevalidate(
202-
cacheKey: string,
203-
fromTime: number,
204-
ctx: Parameters<CacheHandler['get']>[1],
205-
dev?: boolean,
206-
): number | false {
207-
// in development we don't have a prerender-manifest
208-
// and default to always revalidating to allow easier debugging
209-
if (dev) return Date.now() - 1_000
210-
211-
if (ctx?.revalidate && typeof ctx.revalidate === 'number') {
212-
return fromTime + ctx.revalidate * 1_000
213-
}
214-
215-
// if an entry isn't present in routes we fallback to a default
216-
const { initialRevalidateSeconds } = prerenderManifest.routes[toRoute(cacheKey)] || {
217-
initialRevalidateSeconds: 0,
218-
}
219-
// the initialRevalidate can be either set to false or to a number (representing the seconds)
220-
const revalidateAfter: number | false =
221-
typeof initialRevalidateSeconds === 'number'
222-
? initialRevalidateSeconds * 1_000 + fromTime
223-
: initialRevalidateSeconds
224-
225-
return revalidateAfter
226-
}
227182
}
228183

229184
export default NetlifyCacheHandler

‎src/run/handlers/server.ts

+12-6
Original file line numberDiff line numberDiff line change
@@ -53,15 +53,13 @@ export default async (request: Request) => {
5353
// temporary workaround for https://linear.app/netlify/issue/ADN-111/
5454
delete req.headers['accept-encoding']
5555

56-
try {
57-
// console.log('Next server request:', req.url)
58-
await nextHandler(req, resProxy)
59-
} catch (error) {
56+
// We don't await this here, because it won't resolve until the response is finished.
57+
const nextHandlerPromise = nextHandler(req, resProxy).catch((error) => {
6058
logger.withError(error).error('next handler error')
6159
console.error(error)
6260
resProxy.statusCode = 500
6361
resProxy.end('Internal Server Error')
64-
}
62+
})
6563

6664
// Contrary to the docs, this resolves when the headers are available, not when the stream closes.
6765
// See https://github.com/fastly/http-compute-js/blob/main/src/http-compute-js/http-server.ts#L168-L173
@@ -85,5 +83,13 @@ export default async (request: Request) => {
8583
return new Response(body || null, response)
8684
}
8785

88-
return response
86+
const keepOpenUntilNextFullyRendered = new TransformStream({
87+
flush() {
88+
// it's important to keep the stream open until the next handler has finished,
89+
// or otherwise the cache revalidates might not go through
90+
return nextHandlerPromise
91+
},
92+
})
93+
94+
return new Response(response.body?.pipeThrough(keepOpenUntilNextFullyRendered), response)
8995
}

‎src/run/headers.ts

+5-2
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,9 @@ export const setVaryHeaders = (
8282
* from the cache, meaning that the CDN will cache it for 10 seconds, which is incorrect.
8383
*/
8484
export const adjustDateHeader = async (headers: Headers, request: Request) => {
85-
if (headers.get('x-nextjs-cache') !== 'HIT') {
85+
const cacheState = headers.get('x-nextjs-cache')
86+
const isServedFromCache = cacheState === 'HIT' || cacheState === 'STALE'
87+
if (!isServedFromCache) {
8688
return
8789
}
8890
const path = new URL(request.url).pathname
@@ -135,12 +137,13 @@ export const setCacheTagsHeaders = (headers: Headers, request: Request, manifest
135137
/**
136138
* https://httpwg.org/specs/rfc9211.html
137139
*
138-
* We only should get HIT and MISS statuses from Next cache.
140+
* We get HIT, MISS, STALE statuses from Next cache.
139141
* We will ignore other statuses and will not set Cache-Status header in those cases.
140142
*/
141143
const NEXT_CACHE_TO_CACHE_STATUS: Record<string, string> = {
142144
HIT: `hit`,
143145
MISS: `miss,`,
146+
STALE: `hit; fwd=stale`,
144147
}
145148

146149
/**

‎tests/e2e/simple-app.test.ts

+48
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,51 @@ test('next/image is using Netlify Image CDN', async ({ page }) => {
5454

5555
await expectImageWasLoaded(page.locator('img'))
5656
})
57+
58+
const waitFor = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
59+
60+
// adaptation of https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-static/app-static.test.ts#L1716-L1755
61+
test('should stream properly', async ({ page }) => {
62+
// Prime the cache.
63+
const path = `${ctx.url}/stale-cache-serving/app-page`
64+
const res = await fetch(path)
65+
expect(res.status).toBe(200)
66+
67+
// Consume the cache, the revalidations are completed on the end of the
68+
// stream so we need to wait for that to complete.
69+
await res.text()
70+
71+
// different from next.js test:
72+
// we need to wait another 10secs for the blob to propagate back
73+
// can be removed once we have a local cache for blobs
74+
await waitFor(10000)
75+
76+
for (let i = 0; i < 6; i++) {
77+
await waitFor(1000)
78+
79+
const timings = {
80+
start: Date.now(),
81+
startedStreaming: 0,
82+
}
83+
84+
const res = await fetch(path)
85+
86+
// eslint-disable-next-line no-loop-func
87+
await new Promise<void>((resolve) => {
88+
res.body?.pipeTo(
89+
new WritableStream({
90+
write() {
91+
if (!timings.startedStreaming) {
92+
timings.startedStreaming = Date.now()
93+
}
94+
},
95+
close() {
96+
resolve()
97+
},
98+
}),
99+
)
100+
})
101+
102+
expect(timings.startedStreaming - timings.start, `streams in less than 3s, run #${i}/6`).toBeLessThan(3000)
103+
}
104+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
const revalidateSeconds = +process.env.REVALIDATE_SECONDS || 5
2+
const API_BASE = process.env.API_BASE || 'https://api.tvmaze.com/shows/'
3+
4+
async function getData(params) {
5+
const res = await fetch(new URL(params.id, API_BASE).href, {
6+
next: { revalidate: revalidateSeconds },
7+
})
8+
return res.json()
9+
}
10+
11+
export default async function Page({ params }) {
12+
const data = await getData(params)
13+
14+
return (
15+
<>
16+
<h1>Revalidate Fetch (on dynamic page)</h1>
17+
<p>Revalidating used fetch every {revalidateSeconds} seconds</p>
18+
<dl>
19+
<dt>Show</dt>
20+
<dd data-testid="name">{data.name}</dd>
21+
<dt>Param</dt>
22+
<dd data-testid="id">{params.id}</dd>
23+
<dt>Time</dt>
24+
<dd data-testid="date-now">{Date.now()}</dd>
25+
<dt>Time from fetch response</dt>
26+
<dd data-testid="date-from-response">{data.date ?? 'no-date-in-response'}</dd>
27+
</dl>
28+
</>
29+
)
30+
}
31+
32+
// make page dynamic, but still use fetch cache
33+
export const dynamic = 'force-dynamic'

‎tests/fixtures/revalidate-fetch/app/posts/[id]/page.js

+2
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ export default async function Page({ params }) {
2727
<dd data-testid="id">{params.id}</dd>
2828
<dt>Time</dt>
2929
<dd data-testid="date-now">{Date.now()}</dd>
30+
<dt>Time from fetch response</dt>
31+
<dd data-testid="date-from-response">{data.date ?? 'no-date-in-response'}</dd>
3032
</dl>
3133
</>
3234
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
export const dynamic = 'force-dynamic'
2+
3+
const delay = 3000
4+
5+
export default async function Page(props) {
6+
const start = Date.now()
7+
const data = await fetch(
8+
`https://next-data-api-endpoint.vercel.app/api/delay?delay=${delay}`,
9+
{ next: { revalidate: 3 } }
10+
).then((res) => res.json())
11+
const fetchDuration = Date.now() - start
12+
13+
return (
14+
<>
15+
<p id="data">
16+
{JSON.stringify({ fetchDuration, data, now: Date.now() })}
17+
</p>
18+
</>
19+
)
20+
}

‎tests/integration/cache-handler.test.ts

+108-44
Original file line numberDiff line numberDiff line change
@@ -71,35 +71,66 @@ describe('page router', () => {
7171
'500.html',
7272
])
7373

74+
// blob mtime is unpredictable, so this is just waiting for all blobs used from builds to get stale
75+
await new Promise<void>((resolve) => setTimeout(resolve, 10_000))
76+
7477
// test the function call
7578
const call1 = await invokeFunction(ctx, { url: 'static/revalidate-automatic' })
7679
const call1Date = load(call1.body)('[data-testid="date-now"]').text()
7780
expect(call1.statusCode).toBe(200)
7881
expect(load(call1.body)('h1').text()).toBe('Show #71')
79-
// Because we're using mtime instead of Date.now() first invocation will actually be a cache miss.
80-
expect(call1.headers, 'a cache miss on the first invocation of a prerendered page').toEqual(
82+
// We waited for blobs to get stale so first invocation will actually be a cache hit
83+
// with stale being served, while fresh is generated in background
84+
expect(call1.headers, 'a stale page served with swr').toEqual(
8185
expect.objectContaining({
82-
'cache-status': expect.stringMatching(/"Next.js"; miss/),
86+
'cache-status': '"Next.js"; hit; fwd=stale',
87+
'netlify-cdn-cache-control': 's-maxage=5, stale-while-revalidate=31536000',
88+
}),
89+
)
90+
91+
// wait to have page regenerated in the background
92+
await new Promise<void>((resolve) => setTimeout(resolve, 500))
93+
94+
const call2 = await invokeFunction(ctx, { url: 'static/revalidate-automatic' })
95+
const call2Date = load(call2.body)('[data-testid="date-now"]').text()
96+
expect(call2.statusCode).toBe(200)
97+
expect(load(call2.body)('h1').text()).toBe('Show #71')
98+
expect(
99+
call2.headers,
100+
'fresh page that was generated in background during previous call was served',
101+
).toEqual(
102+
expect.objectContaining({
103+
'cache-status': '"Next.js"; hit',
83104
'netlify-cdn-cache-control': 's-maxage=5, stale-while-revalidate=31536000',
84105
}),
85106
)
107+
expect(
108+
call2Date.localeCompare(call1Date),
109+
'the date of regenerated page is newer than initial stale page',
110+
).toBeGreaterThan(0)
111+
112+
// ping that should serve the stale page for static/revalidate-slow, while revalidating in background
113+
await invokeFunction(ctx, { url: 'static/revalidate-slow' })
86114

87115
// wait to have a stale page
88-
await new Promise<void>((resolve) => setTimeout(resolve, 9_000))
116+
await new Promise<void>((resolve) => setTimeout(resolve, 6_000))
89117

90118
// Ping this now so we can wait in parallel
91119
const callLater = await invokeFunction(ctx, { url: 'static/revalidate-slow' })
92120

93-
// now it should be a cache miss
94-
const call2 = await invokeFunction(ctx, { url: 'static/revalidate-automatic' })
95-
const call2Date = load(call2.body)('[data-testid="date-now"]').text()
96-
expect(call2.statusCode).toBe(200)
97-
expect(call2.headers, 'a cache miss on a stale page').toEqual(
121+
// over 5 seconds since it was regenerated, so we should get stale response,
122+
// while fresh is generated in the background
123+
const call3 = await invokeFunction(ctx, { url: 'static/revalidate-automatic' })
124+
const call3Date = load(call3.body)('[data-testid="date-now"]').text()
125+
expect(call3.statusCode).toBe(200)
126+
expect(call3.headers, 'a stale page served with swr').toEqual(
98127
expect.objectContaining({
99-
'cache-status': expect.stringMatching(/"Next.js"; miss/),
128+
'cache-status': '"Next.js"; hit; fwd=stale',
100129
}),
101130
)
102-
expect(call2Date, 'the date was cached and is matching the initial one').not.toBe(call1Date)
131+
expect(call3Date, 'the date was cached and is matching the initially regenerated one').toBe(
132+
call2Date,
133+
)
103134

104135
// Slow revalidate should still be a hit, but the maxage should be updated
105136
const callLater2 = await invokeFunction(ctx, { url: 'static/revalidate-slow' })
@@ -108,20 +139,23 @@ describe('page router', () => {
108139

109140
expect(callLater2.headers, 'date header matches the cached value').toEqual(
110141
expect.objectContaining({
111-
'cache-status': expect.stringMatching(/"Next.js"; hit/),
142+
'cache-status': '"Next.js"; hit',
112143
date: callLater.headers['date'],
113144
}),
114145
)
115146

116147
// it does not wait for the cache.set so we have to manually wait here until the blob storage got populated
117-
await new Promise<void>((resolve) => setTimeout(resolve, 100))
148+
await new Promise<void>((resolve) => setTimeout(resolve, 500))
118149

119150
// now the page should be in cache again and we should get a cache hit
120-
const call3 = await invokeFunction(ctx, { url: 'static/revalidate-automatic' })
121-
const call3Date = load(call3.body)('[data-testid="date-now"]').text()
122-
expect(call2Date, 'the date was not cached').toBe(call3Date)
123-
expect(call3.statusCode).toBe(200)
124-
expect(call3.headers, 'a cache hit after dynamically regenerating the stale page').toEqual(
151+
const call4 = await invokeFunction(ctx, { url: 'static/revalidate-automatic' })
152+
const call4Date = load(call4.body)('[data-testid="date-now"]').text()
153+
expect(call4Date, 'the date was not cached').not.toBe(call3Date)
154+
expect(call4.statusCode).toBe(200)
155+
expect(
156+
call4.headers,
157+
'a cache hit after dynamically regenerating the stale page in the background',
158+
).toEqual(
125159
expect.objectContaining({
126160
'cache-status': expect.stringMatching(/"Next.js"; hit/),
127161
}),
@@ -148,15 +182,18 @@ describe('app router', () => {
148182
'ad74683e49684ff4fe3d01ba1bef627bc0e38b61fa6bd8244145fbaca87f3c49',
149183
])
150184

185+
// blob mtime is unpredictable, so this is just waiting for all blobs used from builds to get stale
186+
await new Promise<void>((resolve) => setTimeout(resolve, 10_000))
187+
151188
// test the function call
152189
const post1 = await invokeFunction(ctx, { url: 'posts/1' })
153190
const post1Date = load(post1.body)('[data-testid="date-now"]').text()
154191
expect(post1.statusCode).toBe(200)
155192
expect(load(post1.body)('h1').text()).toBe('Revalidate Fetch')
156-
expect(post1.headers, 'a cache miss on the first invocation of a prerendered page').toEqual(
157-
// It will be stale/miss instead of hit
193+
expect(post1.headers, 'a stale response on the first invocation of a prerendered page').toEqual(
194+
// It will be stale instead of hit
158195
expect.objectContaining({
159-
'cache-status': expect.stringMatching(/"Next.js"; miss/),
196+
'cache-status': '"Next.js"; hit; fwd=stale',
160197
'netlify-cdn-cache-control': 's-maxage=5, stale-while-revalidate=31536000',
161198
}),
162199
)
@@ -173,32 +210,33 @@ describe('app router', () => {
173210
)
174211

175212
// wait to have a stale page
176-
await new Promise<void>((resolve) => setTimeout(resolve, 5_000))
213+
await new Promise<void>((resolve) => setTimeout(resolve, 6_000))
177214
// after the dynamic call of `posts/3` it should be in cache, not this is after the timout as the cache set happens async
178215
expect(await ctx.blobStore.get(encodeBlobKey('/posts/3'))).not.toBeNull()
179216

180217
const stale = await invokeFunction(ctx, { url: 'posts/1' })
181218
const staleDate = load(stale.body)('[data-testid="date-now"]').text()
182219
expect(stale.statusCode).toBe(200)
183-
expect(stale.headers, 'a cache miss on a stale page').toEqual(
220+
expect(stale.headers, 'a stale swr page is served').toEqual(
184221
expect.objectContaining({
185-
'cache-status': expect.stringMatching(/"Next.js"; miss/),
222+
'cache-status': '"Next.js"; hit; fwd=stale',
186223
}),
187224
)
188-
// it should have a new date rendered
225+
// it should've been regenerated in the background after the first call
226+
// so the date should be different
189227
expect(staleDate, 'the date was cached and is matching the initial one').not.toBe(post1Date)
190228

191229
// it does not wait for the cache.set so we have to manually wait here until the blob storage got populated
192-
await new Promise<void>((resolve) => setTimeout(resolve, 100))
230+
await new Promise<void>((resolve) => setTimeout(resolve, 500))
193231

194232
// now the page should be in cache again and we should get a cache hit
195233
const cached = await invokeFunction(ctx, { url: 'posts/1' })
196234
const cachedDate = load(cached.body)('[data-testid="date-now"]').text()
197235
expect(cached.statusCode).toBe(200)
198-
expect(staleDate, 'the date was not cached').toBe(cachedDate)
236+
expect(cachedDate, 'the date is not stale').not.toBe(staleDate)
199237
expect(cached.headers, 'a cache hit after dynamically regenerating the stale page').toEqual(
200238
expect.objectContaining({
201-
'cache-status': expect.stringMatching(/"Next.js"; hit/),
239+
'cache-status': '"Next.js"; hit',
202240
}),
203241
)
204242
})
@@ -240,7 +278,10 @@ describe('route', () => {
240278
})
241279
expect(blobEntry).not.toBeNull()
242280

243-
// test the first invocation of the route
281+
// blob mtime is unpredictable, so this is just waiting for all blobs used from builds to get stale
282+
await new Promise<void>((resolve) => setTimeout(resolve, 10_000))
283+
284+
// test the first invocation of the route - we should get stale response while fresh is generated in the background
244285
const call1 = await invokeFunction(ctx, { url: '/api/revalidate-handler' })
245286
const call1Body = JSON.parse(call1.body)
246287
const call1Time = call1Body.time
@@ -251,40 +292,63 @@ describe('route', () => {
251292
name: 'Under the Dome',
252293
}),
253294
})
254-
expect(call1.headers, 'a cache miss on the first invocation').toEqual(
295+
expect(call1.headers, 'a stale route served with swr').toEqual(
255296
expect.objectContaining({
256-
'cache-status': expect.stringMatching(/"Next.js"; miss/),
297+
'cache-status': '"Next.js"; hit; fwd=stale',
257298
}),
258299
)
259-
// wait to have a stale route
260-
await new Promise<void>((resolve) => setTimeout(resolve, 7_000))
300+
301+
// it does not wait for the cache.set so we have to manually wait here until the blob storage got populated
302+
await new Promise<void>((resolve) => setTimeout(resolve, 500))
261303

262304
const call2 = await invokeFunction(ctx, { url: '/api/revalidate-handler' })
263305
const call2Body = JSON.parse(call2.body)
264306
const call2Time = call2Body.time
265307
expect(call2.statusCode).toBe(200)
266-
// it should have a new date rendered
267-
expect(call2Body).toMatchObject({ data: expect.objectContaining({ id: 1 }) })
268-
expect(call2.headers, 'a cache miss on a stale route').toEqual(
308+
expect(call2Body).toMatchObject({
309+
data: expect.objectContaining({
310+
id: 1,
311+
name: 'Under the Dome',
312+
}),
313+
})
314+
expect(call2.headers, 'a cache hit on the second invocation').toEqual(
269315
expect.objectContaining({
270-
'cache-status': expect.stringMatching(/"Next.js"; miss/),
316+
'cache-status': '"Next.js"; hit',
271317
}),
272318
)
273-
expect(call1Time, 'the date is a new one on a stale route').not.toBe(call2Time)
319+
expect(call2Time, 'the date is new').not.toBe(call1Time)
274320

275-
// it does not wait for the cache.set so we have to manually wait here until the blob storage got populated
276-
await new Promise<void>((resolve) => setTimeout(resolve, 100))
321+
// wait to have a stale route again
322+
await new Promise<void>((resolve) => setTimeout(resolve, 8_000))
277323

278324
const call3 = await invokeFunction(ctx, { url: '/api/revalidate-handler' })
279-
expect(call3.statusCode).toBe(200)
280325
const call3Body = JSON.parse(call3.body)
281326
const call3Time = call3Body.time
327+
expect(call3.statusCode).toBe(200)
282328
expect(call3Body).toMatchObject({ data: expect.objectContaining({ id: 1 }) })
283-
expect(call3.headers, 'a cache hit after dynamically regenerating the stale route').toEqual(
329+
expect(call3.headers, 'a stale route served with swr').toEqual(
284330
expect.objectContaining({
285-
'cache-status': expect.stringMatching(/"Next.js"; hit/),
331+
'cache-status': '"Next.js"; hit; fwd=stale',
332+
}),
333+
)
334+
expect(
335+
call2Time,
336+
'the date is the old one on the stale route, while the refresh is happening in the background',
337+
).toBe(call3Time)
338+
339+
// it does not wait for the cache.set so we have to manually wait here until the blob storage got populated
340+
await new Promise<void>((resolve) => setTimeout(resolve, 500))
341+
342+
const call4 = await invokeFunction(ctx, { url: '/api/revalidate-handler' })
343+
expect(call4.statusCode).toBe(200)
344+
const call4Body = JSON.parse(call4.body)
345+
const call4Time = call4Body.time
346+
expect(call4Body).toMatchObject({ data: expect.objectContaining({ id: 1 }) })
347+
expect(call4.headers, 'a cache hit after dynamically regenerating the stale route').toEqual(
348+
expect.objectContaining({
349+
'cache-status': '"Next.js"; hit',
286350
}),
287351
)
288-
expect(call3Time, 'the date was cached as well').toBe(call2Time)
352+
expect(call4Time, 'the date is new').not.toBe(call3Time)
289353
})
290354
})

‎tests/integration/fetch-handler.test.ts

+254-61
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,7 @@ import {
1111
type FixtureTestContext,
1212
runPluginStep,
1313
} from '../utils/fixture.js'
14-
import {
15-
decodeBlobKey,
16-
encodeBlobKey,
17-
generateRandomObjectID,
18-
getBlobEntries,
19-
getFetchCacheKey,
20-
startMockBlobStore,
21-
changeMDate,
22-
} from '../utils/helpers.js'
23-
import { existsSync } from 'node:fs'
24-
import { join } from 'node:path'
14+
import { generateRandomObjectID, startMockBlobStore } from '../utils/helpers.js'
2515

2616
// Disable the verbose logging of the lambda-local runtime
2717
getLogger().level = 'alert'
@@ -51,7 +41,8 @@ beforeEach<FixtureTestContext>(async (ctx) => {
5141
'Content-Type': 'application/json',
5242
'cache-control': 'public, max-age=10000',
5343
})
54-
res.end(JSON.stringify({ id: '1', name: 'Fake response' }))
44+
const date = new Date().toISOString()
45+
res.end(JSON.stringify({ id: '1', name: 'Fake response', date }))
5546
})
5647
apiBase = await new Promise<string>((resolve) => {
5748
// we need always the same port so that the hash is the same
@@ -67,78 +58,280 @@ afterEach(async () => {
6758
})
6859
})
6960

70-
test<FixtureTestContext>('if the fetch call is cached correctly', async (ctx) => {
61+
test<FixtureTestContext>('if the fetch call is cached correctly (force-dynamic page)', async (ctx) => {
7162
await createFixture('revalidate-fetch', ctx)
72-
console.time('TimeUntilStale')
73-
const {
74-
constants: { PUBLISH_DIR },
75-
} = await runPluginStep(ctx, 'onPreBuild')
76-
const originalKey = '460ed46cd9a194efa197be9f2571e51b729a039d1cff9834297f416dce5ada29'
77-
78-
const filePath = join(PUBLISH_DIR, 'cache/fetch-cache', originalKey)
79-
if (existsSync(filePath)) {
80-
// Changing the fetch files modified date to a past date since the test files are copied and dont preserve the mtime locally
81-
await changeMDate(filePath, 1674690060000)
82-
}
63+
await runPluginStep(ctx, 'onPreBuild')
8364
await runPlugin(ctx)
8465

85-
// replace the build time fetch cache with our mocked hash
86-
const cacheKey = await getFetchCacheKey(new URL('/1', apiBase).href)
87-
const fakeKey = cacheKey
88-
const fetchEntry = await ctx.blobStore.get(encodeBlobKey(originalKey), { type: 'json' })
89-
90-
await Promise.all([
91-
// delete the page cache so that it falls back to the fetch call
92-
ctx.blobStore.delete(encodeBlobKey('/posts/1')),
93-
// delete the original key as we use the fake key only
94-
ctx.blobStore.delete(encodeBlobKey(originalKey)),
95-
ctx.blobStore.setJSON(encodeBlobKey(fakeKey), fetchEntry),
96-
])
97-
98-
const blobEntries = await getBlobEntries(ctx)
99-
expect(blobEntries.map(({ key }) => decodeBlobKey(key)).sort()).toEqual(
100-
[
101-
'/404',
102-
'/index',
103-
'/posts/2',
104-
fakeKey,
105-
'404.html',
106-
'500.html',
107-
'ad74683e49684ff4fe3d01ba1bef627bc0e38b61fa6bd8244145fbaca87f3c49',
108-
].sort(),
66+
// blob mtime is unpredictable, so this is just waiting for all blobs used from builds to get stale
67+
await new Promise<void>((resolve) => setTimeout(resolve, 10_000))
68+
69+
handlerCalled = 0
70+
const post1 = await invokeFunction(ctx, {
71+
url: 'dynamic-posts/1',
72+
env: {
73+
REVALIDATE_SECONDS: 5,
74+
API_BASE: apiBase,
75+
},
76+
})
77+
78+
// allow for background regeneration to happen
79+
await new Promise<void>((resolve) => setTimeout(resolve, 500))
80+
81+
const post1FetchDate = load(post1.body)('[data-testid="date-from-response"]').text()
82+
const post1Name = load(post1.body)('[data-testid="name"]').text()
83+
84+
expect(
85+
handlerCalled,
86+
'API should be hit as fetch did NOT happen during build for dynamic page',
87+
).toBeGreaterThan(0)
88+
expect(post1.statusCode).toBe(200)
89+
expect(post1Name).toBe('Fake response')
90+
expect(post1.headers, 'the page should not be cacheable').toEqual(
91+
expect.not.objectContaining({
92+
'cache-status': expect.any(String),
93+
}),
94+
)
95+
96+
handlerCalled = 0
97+
const post2 = await invokeFunction(ctx, {
98+
url: 'dynamic-posts/1',
99+
env: {
100+
REVALIDATE_SECONDS: 5,
101+
API_BASE: apiBase,
102+
},
103+
})
104+
105+
// allow for any potential background regeneration to happen
106+
await new Promise<void>((resolve) => setTimeout(resolve, 500))
107+
108+
const post2FetchDate = load(post2.body)('[data-testid="date-from-response"]').text()
109+
const post2Name = load(post2.body)('[data-testid="name"]').text()
110+
111+
expect(handlerCalled, 'API should NOT be hit as fetch-cache is still fresh').toBe(0)
112+
expect(post2FetchDate, 'Cached fetch response should be used').toBe(post1FetchDate)
113+
expect(post2.statusCode).toBe(200)
114+
expect(post2Name).toBe('Fake response')
115+
expect(post2.headers, 'the page should not be cacheable').toEqual(
116+
expect.not.objectContaining({
117+
'cache-status': expect.any(String),
118+
}),
119+
)
120+
121+
// make fetch-cache stale
122+
await new Promise<void>((resolve) => setTimeout(resolve, 7_000))
123+
124+
handlerCalled = 0
125+
const post3 = await invokeFunction(ctx, {
126+
url: 'dynamic-posts/1',
127+
env: {
128+
REVALIDATE_SECONDS: 5,
129+
API_BASE: apiBase,
130+
},
131+
})
132+
133+
// allow for any potential background regeneration to happen
134+
await new Promise<void>((resolve) => setTimeout(resolve, 500))
135+
136+
const post3FetchDate = load(post3.body)('[data-testid="date-from-response"]').text()
137+
const post3Name = load(post3.body)('[data-testid="name"]').text()
138+
139+
// note here that we are testing if API was called it least once and not that it was
140+
// hit exactly once - this is because of Next.js quirk that seems to cause multiple
141+
// fetch calls being made for single request
142+
// https://github.com/vercel/next.js/issues/44655
143+
expect(
144+
handlerCalled,
145+
'API should be hit as fetch did go stale and should be revalidated',
146+
).toBeGreaterThan(0)
147+
expect(
148+
post3FetchDate,
149+
'Cached fetch response should be used (revalidation happen in background)',
150+
).toBe(post1FetchDate)
151+
expect(post3.statusCode).toBe(200)
152+
expect(post3Name).toBe('Fake response')
153+
expect(post3.headers, 'the page should not be cacheable').toEqual(
154+
expect.not.objectContaining({
155+
'cache-status': expect.any(String),
156+
}),
157+
)
158+
159+
handlerCalled = 0
160+
const post4 = await invokeFunction(ctx, {
161+
url: 'dynamic-posts/1',
162+
env: {
163+
REVALIDATE_SECONDS: 5,
164+
API_BASE: apiBase,
165+
},
166+
})
167+
168+
// allow for any potential background regeneration to happen
169+
await new Promise<void>((resolve) => setTimeout(resolve, 500))
170+
171+
const post4FetchDate = load(post4.body)('[data-testid="date-from-response"]').text()
172+
const post4Name = load(post4.body)('[data-testid="name"]').text()
173+
174+
expect(
175+
handlerCalled,
176+
'API should NOT be hit as fetch-cache is still fresh after being revalidated in background by previous request',
177+
).toBe(0)
178+
expect(
179+
post4FetchDate,
180+
'Response cached in background by previous request should be used',
181+
).not.toBe(post3FetchDate)
182+
expect(post4.statusCode).toBe(200)
183+
expect(post4Name).toBe('Fake response')
184+
expect(post4.headers, 'the page should not be cacheable').toEqual(
185+
expect.not.objectContaining({
186+
'cache-status': expect.any(String),
187+
}),
109188
)
189+
})
190+
191+
test<FixtureTestContext>('if the fetch call is cached correctly (cached page response)', async (ctx) => {
192+
await createFixture('revalidate-fetch', ctx)
193+
await runPluginStep(ctx, 'onPreBuild')
194+
await runPlugin(ctx)
195+
196+
// blob mtime is unpredictable, so this is just waiting for all blobs used from builds to get stale
197+
await new Promise<void>((resolve) => setTimeout(resolve, 10_000))
198+
199+
handlerCalled = 0
110200
const post1 = await invokeFunction(ctx, {
111201
url: 'posts/1',
112202
env: {
113-
REVALIDATE_SECONDS: 10,
203+
REVALIDATE_SECONDS: 5,
114204
API_BASE: apiBase,
115205
},
116206
})
117-
console.timeEnd('TimeUntilStale')
118207

208+
// allow for background regeneration to happen
209+
await new Promise<void>((resolve) => setTimeout(resolve, 500))
210+
211+
const post1FetchDate = load(post1.body)('[data-testid="date-from-response"]').text()
119212
const post1Name = load(post1.body)('[data-testid="name"]').text()
120-
// Will still end up calling he API on initial request with us using mtime for lastModified
121-
expect(handlerCalled, 'should not call the API as the request should be cached').toBe(1)
213+
214+
expect(handlerCalled, 'API should be hit as page was revalidated in background').toBeGreaterThan(
215+
0,
216+
)
122217
expect(post1.statusCode).toBe(200)
123-
expect(post1Name).toBe('Fake response')
124-
expect(post1.headers, 'the page should be a miss').toEqual(
218+
expect(post1Name, 'a stale page served with swr').not.toBe('Fake response')
219+
expect(post1.headers, 'a stale page served with swr').toEqual(
125220
expect.objectContaining({
126-
'cache-status': expect.stringMatching(/"Next.js"; miss/),
221+
'cache-status': '"Next.js"; hit; fwd=stale',
222+
'netlify-cdn-cache-control': 's-maxage=5, stale-while-revalidate=31536000',
127223
}),
128224
)
129225

130-
await new Promise<void>((resolve) => setTimeout(resolve, 10_000))
131-
// delete the generated page again to have a miss but go to the underlaying fetch call
132-
await ctx.blobStore.delete('server/app/posts/1')
226+
handlerCalled = 0
133227
const post2 = await invokeFunction(ctx, {
134228
url: 'posts/1',
135229
env: {
136-
REVALIDATE_SECONDS: 10,
230+
REVALIDATE_SECONDS: 5,
137231
API_BASE: apiBase,
138232
},
139233
})
234+
235+
// allow for any potential background regeneration to happen
236+
await new Promise<void>((resolve) => setTimeout(resolve, 500))
237+
238+
const post2FetchDate = load(post2.body)('[data-testid="date-from-response"]').text()
140239
const post2Name = load(post2.body)('[data-testid="name"]').text()
240+
241+
expect(
242+
handlerCalled,
243+
'API should NOT be hit as fetch-cache is still fresh after being revalidated in background by previous request',
244+
).toBe(0)
245+
expect(
246+
post2FetchDate,
247+
'Response cached after being revalidated in background should be now used',
248+
).not.toBe(post1FetchDate)
141249
expect(post2.statusCode).toBe(200)
142-
expect.soft(post2Name).toBe('Fake response')
143-
expect(handlerCalled).toBe(2)
250+
expect(
251+
post2Name,
252+
'Response cached after being revalidated in background should be now used',
253+
).toBe('Fake response')
254+
expect(
255+
post2.headers,
256+
'Still fresh response after being regenerated in background by previous request',
257+
).toEqual(
258+
expect.objectContaining({
259+
'cache-status': '"Next.js"; hit',
260+
'netlify-cdn-cache-control': 's-maxage=5, stale-while-revalidate=31536000',
261+
}),
262+
)
263+
264+
// make response and fetch-cache stale
265+
await new Promise<void>((resolve) => setTimeout(resolve, 7_000))
266+
267+
handlerCalled = 0
268+
const post3 = await invokeFunction(ctx, {
269+
url: 'posts/1',
270+
env: {
271+
REVALIDATE_SECONDS: 5,
272+
API_BASE: apiBase,
273+
},
274+
})
275+
276+
// allow for any potential background regeneration to happen
277+
await new Promise<void>((resolve) => setTimeout(resolve, 500))
278+
279+
const post3FetchDate = load(post3.body)('[data-testid="date-from-response"]').text()
280+
const post3Name = load(post3.body)('[data-testid="name"]').text()
281+
282+
// note here that we are testing if API was called it least once and not that it was
283+
// hit exactly once - this is because of Next.js quirk that seems to cause multiple
284+
// fetch calls being made for single request
285+
// https://github.com/vercel/next.js/issues/44655
286+
expect(
287+
handlerCalled,
288+
'API should be hit as fetch did go stale and should be revalidated',
289+
).toBeGreaterThan(0)
290+
expect(
291+
post3FetchDate,
292+
'Cached fetch response should be used (revalidation happen in background)',
293+
).toBe(post2FetchDate)
294+
expect(post3.statusCode).toBe(200)
295+
expect(post3Name).toBe('Fake response')
296+
expect(post3.headers, 'a stale page served with swr').toEqual(
297+
expect.objectContaining({
298+
'cache-status': '"Next.js"; hit; fwd=stale',
299+
'netlify-cdn-cache-control': 's-maxage=5, stale-while-revalidate=31536000',
300+
}),
301+
)
302+
303+
handlerCalled = 0
304+
const post4 = await invokeFunction(ctx, {
305+
url: 'posts/1',
306+
env: {
307+
REVALIDATE_SECONDS: 5,
308+
API_BASE: apiBase,
309+
},
310+
})
311+
312+
// allow for any potential background regeneration to happen
313+
await new Promise<void>((resolve) => setTimeout(resolve, 500))
314+
315+
const post4FetchDate = load(post4.body)('[data-testid="date-from-response"]').text()
316+
const post4Name = load(post4.body)('[data-testid="name"]').text()
317+
318+
expect(
319+
handlerCalled,
320+
'API should NOT be hit as fetch-cache is still fresh after being revalidated in background by previous request',
321+
).toBe(0)
322+
expect(
323+
post4FetchDate,
324+
'Response cached in background by previous request should be used',
325+
).not.toBe(post3FetchDate)
326+
expect(post4.statusCode).toBe(200)
327+
expect(post4Name).toBe('Fake response')
328+
expect(
329+
post4.headers,
330+
'Still fresh response after being regenerated in background by previous request',
331+
).toEqual(
332+
expect.objectContaining({
333+
'cache-status': '"Next.js"; hit',
334+
'netlify-cdn-cache-control': 's-maxage=5, stale-while-revalidate=31536000',
335+
}),
336+
)
144337
})

‎tests/integration/page-router.test.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,14 @@ let server: ReturnType<typeof setupServer>
1818

1919
beforeAll(() => {
2020
// Enable API mocking before tests.
21-
//api.netlify.com/api/v1/purge
21+
// mock just api.netlify.com/api/v1/purge
22+
// and passthrough everything else
2223
server = setupServer(
23-
http.all(/^http:\/\/localhost:.*/, () => passthrough()),
24-
http.all(/^https:\/\/tvproxy.*/, () => passthrough()),
2524
http.post('https://api.netlify.com/api/v1/purge', () => {
2625
console.log('intercepted purge api call')
2726
return HttpResponse.json({})
2827
}),
28+
http.all(/.*/, () => passthrough()),
2929
)
3030

3131
server.listen()

‎tests/utils/fixture.ts

+10
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,16 @@ function installDependencies(cwd: string) {
5959
* @param fixture name of the folder inside the fixtures folder
6060
*/
6161
export const createFixture = async (fixture: string, ctx: FixtureTestContext) => {
62+
// if we did run lambda from other fixture before it will set some global flags
63+
// that would prevent Next.js from patching it again meaning that future function
64+
// invocations would not use fetch-cache at all - this restores the original fetch
65+
// and makes globalThis.fetch.__nextPatched falsy which will allow Next.js to apply
66+
// needed patch
67+
// @ts-ignore fetch doesn't have __nextPatched property in types
68+
if (globalThis.fetch.__nextPatched && globalThis._nextOriginalFetch) {
69+
globalThis.fetch = globalThis._nextOriginalFetch
70+
}
71+
6272
ctx.cwd = await mkdtemp(join(tmpdir(), 'netlify-next-runtime-'))
6373
vi.spyOn(process, 'cwd').mockReturnValue(ctx.cwd)
6474

0 commit comments

Comments
 (0)
Please sign in to comment.