Skip to content

Commit a6f3ce2

Browse files
taty2010orinokai
andauthored
feat: cache tags & on-demand Revalidation for pages (#50)
* feat: proxying res obj * check blob for cachetag * add cache-tags to pages/app * added types * removed hard coded deploy ID * removed extra comma * fix tag format for purge and add manifest * refactoring * add pageData var * updated setcacheheaders logic * initial testing for cache-tags * updated unit test page data * cache-tags integration tests for app * page router tests * integration test for pages revalidate * lock file * chore: fix tests * chore: update tests * feat: readd missing mockRequestHandlers * chore: fix tests --------- Co-authored-by: Rob Stanford <[email protected]>
1 parent af9dd8f commit a6f3ce2

16 files changed

+1768
-115
lines changed

package-lock.json

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

package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
"homepage": "https://github.com/netlify/next-runtime-minimal#readme",
4040
"dependencies": {
4141
"@fastly/http-compute-js": "1.1.1",
42-
"@netlify/blobs": "^4.0.0",
42+
"@netlify/blobs": "^4.1.0",
4343
"@netlify/build": "^29.20.6",
4444
"@netlify/functions": "^2.0.1",
4545
"@vercel/nft": "^0.24.3",
@@ -61,6 +61,7 @@
6161
"get-port": "^7.0.0",
6262
"lambda-local": "^2.1.2",
6363
"memfs": "^4.6.0",
64+
"msw": "^2.0.7",
6465
"next": "^13.5.4",
6566
"typescript": "^5.1.6",
6667
"unionfs": "^4.5.1",

src/build/content/prerendered.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export type CacheEntryValue = {
1919
value: PageCacheValue | RouteCacheValue | FetchCacheValue
2020
}
2121

22-
type PageCacheValue = {
22+
export type PageCacheValue = {
2323
kind: 'PAGE'
2424
html: string
2525
pageData: string

src/run/handlers/cache.cts

+28-4
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { NEXT_CACHE_TAGS_HEADER } from 'next/dist/lib/constants.js'
1313
import { join } from 'node:path/posix'
1414
// @ts-expect-error This is a type only import
1515
import type { CacheEntryValue } from '../../build/content/prerendered.js'
16+
import type { PrerenderManifest } from 'next/dist/build/index.js'
1617

1718
type TagManifest = { revalidatedAt: number }
1819

@@ -29,7 +30,28 @@ function toRoute(pathname: string): string {
2930
return pathname.replace(/\/$/, '').replace(/\/index$/, '') || '/'
3031
}
3132

32-
export default class NetlifyCacheHandler implements CacheHandler {
33+
// borrowed from https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/page-path/normalize-page-path.ts#L14
34+
function ensureLeadingSlash(path: string): string {
35+
return path.startsWith('/') ? path : `/${path}`
36+
}
37+
38+
function isDynamicRoute(path: string): void | boolean {
39+
const dynamicRoutes: PrerenderManifest['dynamicRoutes'] = prerenderManifest.dynamicRoutes
40+
Object.values(dynamicRoutes).find((route) => {
41+
return new RegExp(route.routeRegex).test(path)
42+
})
43+
}
44+
45+
function normalizePath(path: string): string {
46+
// If there is a page that is '/index' the first statement ensures that it will be '/index/index'
47+
return /^\/index(\/|$)/.test(path) && !isDynamicRoute(path)
48+
? `/index${path}`
49+
: path === '/'
50+
? '/index'
51+
: ensureLeadingSlash(path)
52+
}
53+
54+
module.exports = class NetlifyCacheHandler implements CacheHandler {
3355
options: CacheHandlerContext
3456
revalidatedTags: string[]
3557
/** Indicates if the application is using the new appDir */
@@ -156,8 +178,10 @@ export default class NetlifyCacheHandler implements CacheHandler {
156178
isAppPath: boolean
157179
} & CacheEntryValue)
158180
> {
159-
const appKey = join('server/app', key)
160-
const pagesKey = join('server/pages', key)
181+
const normalizedKey = normalizePath(key)
182+
// Want to avoid normalizaing if '/index' is being passed as a key.
183+
const appKey = join('server/app', key === '/index' ? key : normalizedKey)
184+
const pagesKey = join('server/pages', key === '/index' ? key : normalizedKey)
161185
const fetchKey = join('cache/fetch-cache', key)
162186

163187
if (fetch) {
@@ -223,7 +247,7 @@ export default class NetlifyCacheHandler implements CacheHandler {
223247

224248
const isStale = cacheTags.some((tag) => {
225249
// TODO: test for this case
226-
if (this.revalidatedTags.includes(tag)) {
250+
if (tag && this.revalidatedTags?.includes(tag)) {
227251
return true
228252
}
229253

src/run/handlers/server.ts

+46-9
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,18 @@ import { toComputeResponse, toReqRes } from '@fastly/http-compute-js'
22
import { HeadersSentEvent } from '@fastly/http-compute-js/dist/http-compute-js/http-outgoing.js'
33
import type { NextConfigComplete } from 'next/dist/server/config-shared.js'
44
import type { WorkerRequestHandler } from 'next/dist/server/lib/types.js'
5+
import { readFileSync } from 'node:fs'
6+
import { join } from 'node:path/posix'
7+
import { CacheEntryValue, PageCacheValue } from '../../build/content/prerendered.js'
58
import { RUN_DIR } from '../constants.js'
69
import { setCacheControlHeaders, setCacheTagsHeaders, setVaryHeaders } from '../headers.js'
10+
import { nextResponseProxy } from '../revalidate.js'
711

8-
let nextHandler: WorkerRequestHandler, nextConfig: NextConfigComplete
12+
export type PageCacheEntry = CacheEntryValue & { value: PageCacheValue }
13+
14+
let nextHandler: WorkerRequestHandler,
15+
nextConfig: NextConfigComplete,
16+
cacheEntry: PageCacheEntry | undefined | null
917

1018
export default async (request: Request) => {
1119
if (!nextHandler) {
@@ -14,6 +22,8 @@ export default async (request: Request) => {
1422
nextConfig = await getRunConfig()
1523
setRunConfig(nextConfig)
1624

25+
cacheEntry = await getCacheEntry(request)
26+
1727
const { getMockedRequestHandlers } = await import('./next.cjs')
1828

1929
;[nextHandler] = await getMockedRequestHandlers({
@@ -26,27 +36,54 @@ export default async (request: Request) => {
2636

2737
const { req, res } = toReqRes(request)
2838

29-
res.prependListener('_headersSent', (event: HeadersSentEvent) => {
39+
const resProxy = nextResponseProxy(res)
40+
41+
resProxy.prependListener('_headersSent', (event: HeadersSentEvent) => {
3042
const headers = new Headers(event.headers)
3143
setCacheControlHeaders(headers)
32-
setCacheTagsHeaders(headers)
44+
setCacheTagsHeaders(request, headers, cacheEntry)
3345
setVaryHeaders(headers, request, nextConfig)
3446
event.headers = Object.fromEntries(headers.entries())
3547
// console.log('Modified response headers:', JSON.stringify(event.headers, null, 2))
3648
})
3749

3850
try {
39-
console.log('Next server request:', req.url)
40-
await nextHandler(req, res)
51+
// console.log('Next server request:', req.url)
52+
await nextHandler(req, resProxy)
4153
} catch (error) {
4254
console.error(error)
43-
res.statusCode = 500
44-
res.end('Internal Server Error')
55+
resProxy.statusCode = 500
56+
resProxy.end('Internal Server Error')
4557
}
4658

4759
// log the response from Next.js
48-
const response = { headers: res.getHeaders(), statusCode: res.statusCode }
60+
const response = {
61+
headers: resProxy.getHeaders(),
62+
statusCode: resProxy.statusCode,
63+
}
4964
// console.log('Next server response:', JSON.stringify(response, null, 2))
5065

51-
return toComputeResponse(res)
66+
return toComputeResponse(resProxy)
67+
}
68+
69+
const prerenderManifest = JSON.parse(
70+
readFileSync(join(process.cwd(), '.next/prerender-manifest.json'), 'utf-8'),
71+
)
72+
73+
const getCacheEntry = async (request: Request) => {
74+
// dynamically importing to avoid calling NetlifyCacheHandler beforhand
75+
// @ts-expect-error
76+
const NetlifyCacheHandler = await import('../../../dist/run/handlers/cache.cjs')
77+
// Have to assign NetlifyCacheHandler.default to new variable to prevent error: `X is not a constructor`
78+
const CacheHandler = NetlifyCacheHandler.default
79+
const cache = new CacheHandler({
80+
_appDir: true,
81+
revalidateTags: [],
82+
_requestHandler: {},
83+
})
84+
const path = new URL(request.url).pathname
85+
// Checking if route is in prerender manifest before retrieving pageData from Blob
86+
if (prerenderManifest.routes[path]) {
87+
return await cache.get(path, { type: 'json' })
88+
}
5289
}

src/run/headers.test.ts

+54-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { expect, test, describe, vi, afterEach } from 'vitest'
22
import { setCacheControlHeaders, setVaryHeaders, setCacheTagsHeaders } from './headers.js'
33
import type { NextConfigComplete } from 'next/dist/server/config-shared.js'
4+
import { PageCacheValue } from '../build/content/prerendered.js'
45

56
describe('headers', () => {
67
afterEach(() => {
@@ -243,8 +244,59 @@ describe('headers', () => {
243244
})
244245

245246
describe('setCacheTagsHeaders', () => {
246-
test('TODO: function is not yet implemented', () => {
247-
expect(setCacheTagsHeaders(new Headers())).toBeUndefined()
247+
const appValue = {
248+
kind: 'PAGE',
249+
html: '<!DOCTYPE html><html lang="en">',
250+
pageData: 'Data from rsc file',
251+
headers: { 'x-next-cache-tags': '_N_T_/layout,_N_T_/page,_N_T_/' },
252+
status: 200,
253+
} satisfies PageCacheValue
254+
255+
const pageValue = {
256+
kind: 'PAGE',
257+
html: '<!DOCTYPE html><html lang="en">',
258+
pageData: { pageProps: { foo: 'bar' } },
259+
status: 200,
260+
} satisfies PageCacheValue | { pageData: Object }
261+
262+
test('Should set cache-tag header for app routes using tag in headers[x-next-cache-tags] from cache value', () => {
263+
const cacheEntry = {
264+
lastModified: 1699843226944,
265+
value: appValue,
266+
}
267+
268+
const headers = new Headers()
269+
vi.spyOn(headers, 'set')
270+
setCacheTagsHeaders(new Request('https://example.com/index'), headers, cacheEntry)
271+
272+
expect(headers.set).toHaveBeenNthCalledWith(1, 'cache-tag', '_N_T_/layout,_N_T_/page,_N_T_/')
273+
})
274+
275+
test('Should set cache-tag header for page routes using pathname as the tag', () => {
276+
const cacheEntry: any = {
277+
lastModified: 1699843226944,
278+
value: pageValue,
279+
}
280+
281+
const headers = new Headers()
282+
vi.spyOn(headers, 'set')
283+
setCacheTagsHeaders(new Request('https://example.com/index'), headers, cacheEntry)
284+
285+
expect(headers.set).toHaveBeenNthCalledWith(1, 'cache-tag', '/index')
286+
})
287+
288+
test('Should not return any cache-tags if data is null', () => {
289+
const cacheEntry: any = {
290+
lastModified: 1699843226944,
291+
value: null,
292+
}
293+
294+
const headers = new Headers()
295+
vi.spyOn(headers, 'set')
296+
297+
expect(
298+
setCacheTagsHeaders(new Request('https://example.com/index'), headers, cacheEntry),
299+
).toBeUndefined()
248300
})
249301
})
250302
})

src/run/headers.ts

+20-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { NextConfigComplete } from 'next/dist/server/config-shared.js'
2+
import type { PageCacheEntry } from './handlers/server.js'
23

34
interface NetlifyVaryValues {
45
headers: string[]
@@ -83,6 +84,23 @@ export const setCacheControlHeaders = (headers: Headers) => {
8384
}
8485
}
8586

86-
export const setCacheTagsHeaders = (headers: Headers) => {
87-
// TODO: implement
87+
export const setCacheTagsHeaders = (
88+
request: Request,
89+
headers: Headers,
90+
cacheEntry: PageCacheEntry | undefined | null,
91+
) => {
92+
const pageData = cacheEntry?.value?.pageData
93+
const cacheHeaders = cacheEntry?.value?.headers
94+
const path = new URL(request.url).pathname
95+
96+
// Page Data for app chould be a string, Pages should be an obj
97+
if (pageData && typeof pageData === 'string' && cacheHeaders?.['x-next-cache-tags']) {
98+
console.debug('Cache values for app routes:', cacheHeaders['x-next-cache-tags'])
99+
headers.set('cache-tag', cacheHeaders['x-next-cache-tags'])
100+
}
101+
102+
if (pageData && typeof pageData === 'object') {
103+
console.debug('Cache values for Page routes', cacheEntry)
104+
headers.set('cache-tag', path)
105+
}
88106
}

src/run/revalidate.ts

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { purgeCache } from '@netlify/functions'
2+
import type { ServerResponse } from 'node:http'
3+
4+
// Needing to proxy the response object to intercept the revalidate call for on-demand revalidation on page routes
5+
export const nextResponseProxy = (res: ServerResponse) => {
6+
return new Proxy(res, {
7+
get(target: any[string], key: string) {
8+
const originalValue = target[key]
9+
if (key === 'revalidate') {
10+
return async function newRevalidate(...args: any[]) {
11+
try {
12+
console.debug('Purging cache for:', [args[0]])
13+
await purgeCache({ tags: [args[0]] })
14+
} catch (err) {
15+
throw new Error(
16+
`An internal error occurred while trying to purge cache for ${args[0]}}`,
17+
)
18+
}
19+
return originalValue?.apply(target, args)
20+
}
21+
}
22+
return originalValue
23+
},
24+
})
25+
}

tests/fixtures/page-router/pages/api/revalidate.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export default async function handler(req, res) {
22
try {
3-
await res.revalidate('/static/with-revalidate')
3+
await res.revalidate('/static/revalidate-manual')
44
return res.json({ code: 200, message: 'success' })
55
} catch (err) {
66
return res.status(500).send({ code: 500, message: err.message })
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
const Show = ({ show, time }) => (
2+
<div>
3+
<p>
4+
This page uses getStaticProps() to pre-fetch a TV show at
5+
<span data-testid="date-now">{time}</span>
6+
</p>
7+
<hr />
8+
<h1>Show #{show.id}</h1>
9+
<p>{show.name}</p>
10+
</div>
11+
)
12+
13+
export async function getStaticProps(context) {
14+
const res = await fetch(`https://tvproxy.netlify.app/shows/71`)
15+
const data = await res.json()
16+
17+
return {
18+
props: {
19+
show: data,
20+
time: new Date().toISOString(),
21+
},
22+
}
23+
}
24+
25+
export default Show

tests/integration/cache-handler.test.ts

+6-5
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,12 @@ describe('page router', () => {
3535
expect(blobEntries.map(({ key }) => key).sort()).toEqual([
3636
'server/pages/404.html',
3737
'server/pages/500.html',
38-
'server/pages/static/revalidate',
38+
'server/pages/static/revalidate-automatic',
39+
'server/pages/static/revalidate-manual',
3940
])
4041

4142
// test the function call
42-
const call1 = await invokeFunction(ctx, { url: 'static/revalidate' })
43+
const call1 = await invokeFunction(ctx, { url: 'static/revalidate-automatic' })
4344
const call1Date = load(call1.body)('[data-testid="date-now"]').text()
4445
expect(call1.statusCode).toBe(200)
4546
expect(load(call1.body)('h1').text()).toBe('Show #71')
@@ -54,7 +55,7 @@ describe('page router', () => {
5455
await new Promise<void>((resolve) => setTimeout(resolve, 3_000))
5556

5657
// now it should be a cache miss
57-
const call2 = await invokeFunction(ctx, { url: 'static/revalidate' })
58+
const call2 = await invokeFunction(ctx, { url: 'static/revalidate-automatic' })
5859
const call2Date = load(call2.body)('[data-testid="date-now"]').text()
5960
expect(call2.statusCode).toBe(200)
6061
expect(call2.headers, 'a cache miss on a stale page').toEqual(
@@ -68,7 +69,7 @@ describe('page router', () => {
6869
await new Promise<void>((resolve) => setTimeout(resolve, 100))
6970

7071
// now the page should be in cache again and we should get a cache hit
71-
const call3 = await invokeFunction(ctx, { url: 'static/revalidate' })
72+
const call3 = await invokeFunction(ctx, { url: 'static/revalidate-automatic' })
7273
const call3Date = load(call3.body)('[data-testid="date-now"]').text()
7374
expect(call2Date, 'the date was not cached').toBe(call3Date)
7475
expect(call3.statusCode).toBe(200)
@@ -205,7 +206,7 @@ describe('route', () => {
205206
}),
206207
)
207208
// wait to have a stale route
208-
await new Promise<void>((resolve) => setTimeout(resolve, 2_000))
209+
await new Promise<void>((resolve) => setTimeout(resolve, 3_000))
209210

210211
const call2 = await invokeFunction(ctx, { url: '/api/revalidate-handler' })
211212
const call2Body = JSON.parse(call2.body)

0 commit comments

Comments
 (0)