Skip to content

Commit 4523c5f

Browse files
ascorbiclukasholzereduardoboucaskodiakhq[bot]
authoredJan 4, 2024
fix: set correct date header for cached objects (#124)
* fix: set correct date header for cached objects * Document the header handling changes * Remove test.only * Mock blob store in headers tests * Fix test * Instantiate blob store at request time * Instantiate store per-request * Update src/run/handlers/server.ts Co-authored-by: Lukas Holzer <lukas.holzer@typeflow.cc> * Update src/run/headers.test.ts Co-authored-by: Eduardo Bouças <mail@eduardoboucas.com> * chore: lint --------- Co-authored-by: Lukas Holzer <lukas.holzer@typeflow.cc> Co-authored-by: Eduardo Bouças <mail@eduardoboucas.com> Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
1 parent 93ebbee commit 4523c5f

File tree

7 files changed

+115
-25
lines changed

7 files changed

+115
-25
lines changed
 

‎src/run/handlers/server.ts

+17-18
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ import type { NextConfigComplete } from 'next/dist/server/config-shared.js'
44
import type { WorkerRequestHandler } from 'next/dist/server/lib/types.js'
55

66
import { TagsManifest, getTagsManifest } from '../config.js'
7-
import { setCacheControlHeaders, setCacheTagsHeaders, setVaryHeaders } from '../headers.js'
7+
import {
8+
adjustDateHeader,
9+
setCacheControlHeaders,
10+
setCacheTagsHeaders,
11+
setVaryHeaders,
12+
} from '../headers.js'
813
import { nextResponseProxy } from '../revalidate.js'
914

1015
let nextHandler: WorkerRequestHandler, nextConfig: NextConfigComplete, tagsManifest: TagsManifest
@@ -30,15 +35,6 @@ export default async (request: Request) => {
3035

3136
const resProxy = nextResponseProxy(res)
3237

33-
resProxy.prependListener('_headersSent', (event: HeadersSentEvent) => {
34-
const headers = new Headers(event.headers)
35-
setCacheControlHeaders(headers)
36-
setCacheTagsHeaders(headers, request, tagsManifest)
37-
setVaryHeaders(headers, request, nextConfig)
38-
event.headers = Object.fromEntries(headers.entries())
39-
// console.log('Modified response headers:', JSON.stringify(event.headers, null, 2))
40-
})
41-
4238
// temporary workaround for https://linear.app/netlify/issue/ADN-111/
4339
delete req.headers['accept-encoding']
4440

@@ -51,13 +47,16 @@ export default async (request: Request) => {
5147
resProxy.end('Internal Server Error')
5248
}
5349

54-
// log the response from Next.js
55-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
56-
const response = {
57-
headers: resProxy.getHeaders(),
58-
statusCode: resProxy.statusCode,
59-
}
60-
// console.log('Next server response:', JSON.stringify(response, null, 2))
50+
// Contrary to the docs, this resolves when the headers are available, not when the stream closes.
51+
// See https://github.com/fastly/http-compute-js/blob/main/src/http-compute-js/http-server.ts#L168-L173
52+
const response = await toComputeResponse(resProxy)
53+
54+
await adjustDateHeader(response.headers, request)
55+
56+
setCacheControlHeaders(response.headers)
57+
setCacheTagsHeaders(response.headers, request, tagsManifest)
58+
setVaryHeaders(response.headers, request, nextConfig)
59+
6160

62-
return toComputeResponse(resProxy)
61+
return response
6362
}

‎src/run/headers.test.ts

+15-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,20 @@
11
import type { NextConfigComplete } from 'next/dist/server/config-shared.js'
2-
import { afterEach, describe, expect, test, vi } from 'vitest'
2+
import { v4 } from 'uuid'
3+
import { afterEach, describe, expect, test, vi, beforeEach } from 'vitest'
34

4-
import { setCacheControlHeaders, setVaryHeaders } from './headers.js'
5+
import { FixtureTestContext } from '../../tests/utils/fixture.js'
6+
import { generateRandomObjectID, startMockBlobStore } from '../../tests/utils/helpers.js'
7+
8+
import { setVaryHeaders, setCacheControlHeaders } from './headers.js'
9+
10+
beforeEach<FixtureTestContext>(async (ctx) => {
11+
// set for each test a new deployID and siteID
12+
ctx.deployID = generateRandomObjectID()
13+
ctx.siteID = v4()
14+
vi.stubEnv('DEPLOY_ID', ctx.deployID)
15+
16+
await startMockBlobStore(ctx)
17+
})
518

619
describe('headers', () => {
720
afterEach(() => {

‎src/run/headers.ts

+34
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { Buffer } from 'node:buffer'
2+
3+
import { getDeployStore } from '@netlify/blobs'
14
import type { NextConfigComplete } from 'next/dist/server/config-shared.js'
25

36
import type { TagsManifest } from './config.js'
@@ -71,6 +74,37 @@ export const setVaryHeaders = (
7174
headers.set(`netlify-vary`, generateNetlifyVaryValues(netlifyVaryValues))
7275
}
7376

77+
function encodeBlobKey(key: string) {
78+
return Buffer.from(key.replace(/^\//, '')).toString('base64')
79+
}
80+
81+
/**
82+
* Change the date header to be the last-modified date of the blob. This means the CDN
83+
* will use the correct expiry time for the response. e.g. if the blob was last modified
84+
* 5 seconds ago and is sent with a maxage of 10, the CDN will cache it for 5 seconds.
85+
* By default, Next.js sets the date header to the current time, even if it's served
86+
* from the cache, meaning that the CDN will cache it for 10 seconds, which is incorrect.
87+
*/
88+
export const adjustDateHeader = async (headers: Headers, request: Request) => {
89+
if (headers.get('x-nextjs-cache') !== 'HIT') {
90+
return
91+
}
92+
const path = new URL(request.url).pathname
93+
const key = encodeBlobKey(path)
94+
const blobStore = getDeployStore()
95+
// TODO: use metadata for this
96+
const { lastModified } = (await blobStore.get(key, { type: 'json' })) ?? {}
97+
98+
if (!lastModified) {
99+
return
100+
}
101+
const lastModifiedDate = new Date(lastModified)
102+
// Show actual date of the function call in the date header
103+
headers.set('x-nextjs-date', headers.get('date') ?? lastModifiedDate.toUTCString())
104+
// Setting Age also would work, but we already have the lastModified time so will use that.
105+
headers.set('date', lastModifiedDate.toUTCString())
106+
}
107+
74108
/**
75109
* Ensure stale-while-revalidate and s-maxage don't leak to the client, but
76110
* assume the user knows what they are doing if CDN cache controls are set

‎src/run/next.cts

+6-5
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,20 @@
1-
import { getDeployStore } from '@netlify/blobs'
21
import fs from 'fs/promises'
32
import { Buffer } from 'node:buffer'
43
import { relative, resolve } from 'path'
54

6-
import type { getRequestHandlers } from 'next/dist/server/lib/start-server.js'
7-
5+
import { getDeployStore } from '@netlify/blobs'
86
// @ts-expect-error no types installed
97
import { patchFs } from 'fs-monkey'
8+
import type { getRequestHandlers } from 'next/dist/server/lib/start-server.js'
109

1110
type FS = typeof import('fs')
1211

1312
export async function getMockedRequestHandlers(...args: Parameters<typeof getRequestHandlers>) {
1413
const store = getDeployStore()
1514
const ofs = { ...fs }
1615

17-
async function readFileFallbackBlobStore(...args: Parameters<FS['promises']['readFile']>) {
18-
const [path, options] = args
16+
async function readFileFallbackBlobStore(...fsargs: Parameters<FS['promises']['readFile']>) {
17+
const [path, options] = fsargs
1918
try {
2019
// Attempt to read from the disk
2120
// important to use the `import * as fs from 'fs'` here to not end up in a endless loop
@@ -40,9 +39,11 @@ export async function getMockedRequestHandlers(...args: Parameters<typeof getReq
4039
{
4140
readFile: readFileFallbackBlobStore,
4241
},
42+
// eslint-disable-next-line n/global-require
4343
require('fs').promises,
4444
)
4545

46+
// eslint-disable-next-line no-shadow
4647
const { getRequestHandlers } = await import('next/dist/server/lib/start-server.js')
4748
return getRequestHandlers(...args)
4849
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
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+
revalidate: +process.env.REVALIDATE_SECONDS || 10, // In seconds
23+
}
24+
}
25+
26+
export default Show

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

+16
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ describe('page router', () => {
4545
'500.html',
4646
'static/revalidate-automatic',
4747
'static/revalidate-manual',
48+
'static/revalidate-slow',
4849
])
4950

5051
// test the function call
@@ -59,6 +60,9 @@ describe('page router', () => {
5960
}),
6061
)
6162

63+
// Ping this now so we can wait in parallel
64+
const callLater = await invokeFunction(ctx, { url: 'static/revalidate-slow' })
65+
6266
// wait to have a stale page
6367
await new Promise<void>((resolve) => setTimeout(resolve, 3_000))
6468

@@ -73,6 +77,18 @@ describe('page router', () => {
7377
)
7478
expect(call2Date, 'the date was cached and is matching the initial one').not.toBe(call1Date)
7579

80+
// Slow revalidate should still be a hit, but the maxage should be updated
81+
const callLater2 = await invokeFunction(ctx, { url: 'static/revalidate-slow' })
82+
83+
expect(callLater2.statusCode).toBe(200)
84+
85+
expect(callLater2.headers, 'date header matches the cached value').toEqual(
86+
expect.objectContaining({
87+
'x-nextjs-cache': 'HIT',
88+
date: callLater.headers['date'],
89+
}),
90+
)
91+
7692
// it does not wait for the cache.set so we have to manually wait here until the blob storage got populated
7793
await new Promise<void>((resolve) => setTimeout(resolve, 100))
7894

‎tests/integration/static.test.ts

+1
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ test<FixtureTestContext>('requesting a non existing page route that needs to be
3939
'500.html',
4040
'static/revalidate-automatic',
4141
'static/revalidate-manual',
42+
'static/revalidate-slow',
4243
])
4344

4445
// test that it should request the 404.html file

0 commit comments

Comments
 (0)
Please sign in to comment.