Skip to content

Commit 4e5d5fc

Browse files
taty2010orinokai
andauthored
fix: LastModified Date (#211)
* copying over mtime changes * initial test changes * update fetch-handler test * added utime func * update fetch-handler test * test change * adding utime for local testing * small test update --------- Co-authored-by: Rob Stanford <[email protected]>
1 parent 3fb5bda commit 4e5d5fc

File tree

5 files changed

+89
-26
lines changed

5 files changed

+89
-26
lines changed

src/build/content/prerendered.ts

+18-8
Original file line numberDiff line numberDiff line change
@@ -53,34 +53,43 @@ export const copyPrerenderedContent = async (ctx: PluginContext): Promise<void>
5353
Object.entries(manifest.routes).map(async ([route, meta]): Promise<void> => {
5454
const key = routeToFilePath(route)
5555
let value: CacheValue
56+
let path: string
5657
switch (true) {
5758
// Parallel route default layout has no prerendered page
5859
case meta.dataRoute?.endsWith('/default.rsc') &&
5960
!existsSync(join(ctx.publishDir, 'server/app', `${key}.html`)):
6061
return
6162
case meta.dataRoute?.endsWith('.json'):
62-
value = await buildPagesCacheValue(join(ctx.publishDir, 'server/pages', key))
63+
path = join(ctx.publishDir, 'server/pages', key)
64+
value = await buildPagesCacheValue(path)
6365
break
6466
case meta.dataRoute?.endsWith('.rsc'):
65-
value = await buildAppCacheValue(join(ctx.publishDir, 'server/app', key))
67+
path = join(ctx.publishDir, 'server/app', key)
68+
value = await buildAppCacheValue(path)
6669
break
6770
case meta.dataRoute === null:
68-
value = await buildRouteCacheValue(join(ctx.publishDir, 'server/app', key))
71+
path = join(ctx.publishDir, 'server/app', key)
72+
value = await buildRouteCacheValue(path)
6973
break
7074
default:
7175
throw new Error(`Unrecognized content: ${route}`)
7276
}
7377

74-
await ctx.writeCacheEntry(key, value)
78+
await ctx.writeCacheEntry(
79+
key,
80+
value,
81+
meta.dataRoute === null ? `${path}.body` : `${path}.html`,
82+
)
7583
}),
7684
)
7785

7886
// app router 404 pages are not in the prerender manifest
7987
// so we need to check for them manually
8088
if (existsSync(join(ctx.publishDir, `server/app/_not-found.html`))) {
8189
const key = '/404'
82-
const value = await buildAppCacheValue(join(ctx.publishDir, 'server/app/_not-found'))
83-
await ctx.writeCacheEntry(key, value)
90+
const path = join(ctx.publishDir, 'server/app/_not-found')
91+
const value = await buildAppCacheValue(path)
92+
await ctx.writeCacheEntry(key, value, `${path}.html`)
8493
}
8594
} catch (error) {
8695
ctx.failBuild('Failed assembling prerendered content for upload', error)
@@ -99,8 +108,9 @@ export const copyFetchContent = async (ctx: PluginContext): Promise<void> => {
99108

100109
await Promise.all(
101110
paths.map(async (key): Promise<void> => {
102-
const value = await buildFetchCacheValue(join(ctx.publishDir, 'cache/fetch-cache', key))
103-
await ctx.writeCacheEntry(key, value)
111+
const path = join(ctx.publishDir, 'cache/fetch-cache', key)
112+
const value = await buildFetchCacheValue(path)
113+
await ctx.writeCacheEntry(key, value, path)
104114
}),
105115
)
106116
} catch (error) {

src/build/plugin-context.ts

+5-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { readFileSync } from 'node:fs'
2-
import { mkdir, readFile, writeFile } from 'node:fs/promises'
2+
import { mkdir, readFile, writeFile, stat } from 'node:fs/promises'
33
// Here we need to actually import `resolve` from node:path as we want to resolve the paths
44
// eslint-disable-next-line no-restricted-imports
55
import { dirname, join, resolve } from 'node:path'
@@ -188,10 +188,12 @@ export class PluginContext {
188188
/**
189189
* Write a cache entry to the blob upload directory.
190190
*/
191-
async writeCacheEntry(route: string, value: CacheValue): Promise<void> {
191+
async writeCacheEntry(route: string, value: CacheValue, filePath: string): Promise<void> {
192+
// Getting modified file date from prerendered content
193+
const { mtime } = await stat(filePath)
192194
const path = join(this.blobDir, await encodeBlobKey(route))
193195
const entry = JSON.stringify({
194-
lastModified: Date.now(),
196+
lastModified: mtime.getTime(),
195197
value,
196198
} satisfies CacheEntry)
197199

tests/integration/cache-handler.test.ts

+32-9
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,19 @@ import {
77
invokeFunction,
88
runPlugin,
99
type FixtureTestContext,
10+
runPluginStep,
1011
} from '../utils/fixture.js'
1112
import {
13+
changeMDate,
1214
decodeBlobKey,
1315
encodeBlobKey,
1416
generateRandomObjectID,
1517
getBlobEntries,
1618
startMockBlobStore,
1719
} from '../utils/helpers.js'
20+
import { join } from 'path'
21+
import { existsSync } from 'node:fs'
22+
import { minify } from 'next/dist/build/swc/index.js'
1823

1924
// Disable the verbose logging of the lambda-local runtime
2025
getLogger().level = 'alert'
@@ -36,6 +41,22 @@ describe('page router', () => {
3641
test<FixtureTestContext>('page router with static revalidate', async (ctx) => {
3742
await createFixture('page-router', ctx)
3843
console.time('runPlugin')
44+
const {
45+
constants: { PUBLISH_DIR },
46+
} = await runPluginStep(ctx, 'onPreBuild')
47+
const filePaths = [
48+
'server/pages/static/revalidate-automatic.html',
49+
'server/pages/static/revalidate-automatic.json',
50+
'server/pages/static/revalidate-slow.html',
51+
'server/pages/static/revalidate-slow.json',
52+
]
53+
54+
filePaths.forEach(async (filePath) => {
55+
if (existsSync(filePath)) {
56+
// Changing the fetch files modified date to a past date since the test files are copied and dont preserve the mtime locally
57+
await changeMDate(join(PUBLISH_DIR, filePath), 1674690060000)
58+
}
59+
})
3960
await runPlugin(ctx)
4061
console.timeEnd('runPlugin')
4162
// check if the blob entries where successful set on the build plugin
@@ -55,19 +76,20 @@ describe('page router', () => {
5576
const call1Date = load(call1.body)('[data-testid="date-now"]').text()
5677
expect(call1.statusCode).toBe(200)
5778
expect(load(call1.body)('h1').text()).toBe('Show #71')
58-
expect(call1.headers, 'a cache hit on the first invocation of a prerendered page').toEqual(
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(
5981
expect.objectContaining({
60-
'cache-status': expect.stringMatching(/"Next.js"; hit/),
82+
'cache-status': expect.stringMatching(/"Next.js"; miss/),
6183
'netlify-cdn-cache-control': 's-maxage=5, stale-while-revalidate=31536000',
6284
}),
6385
)
6486

87+
// wait to have a stale page
88+
await new Promise<void>((resolve) => setTimeout(resolve, 9_000))
89+
6590
// Ping this now so we can wait in parallel
6691
const callLater = await invokeFunction(ctx, { url: 'static/revalidate-slow' })
6792

68-
// wait to have a stale page
69-
await new Promise<void>((resolve) => setTimeout(resolve, 3_000))
70-
7193
// now it should be a cache miss
7294
const call2 = await invokeFunction(ctx, { url: 'static/revalidate-automatic' })
7395
const call2Date = load(call2.body)('[data-testid="date-now"]').text()
@@ -131,9 +153,10 @@ describe('app router', () => {
131153
const post1Date = load(post1.body)('[data-testid="date-now"]').text()
132154
expect(post1.statusCode).toBe(200)
133155
expect(load(post1.body)('h1').text()).toBe('Revalidate Fetch')
134-
expect(post1.headers, 'a cache hit on the first invocation of a prerendered page').toEqual(
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
135158
expect.objectContaining({
136-
'cache-status': expect.stringMatching(/"Next.js"; hit/),
159+
'cache-status': expect.stringMatching(/"Next.js"; miss/),
137160
'netlify-cdn-cache-control': 's-maxage=5, stale-while-revalidate=31536000',
138161
}),
139162
)
@@ -228,9 +251,9 @@ describe('route', () => {
228251
name: 'Under the Dome',
229252
}),
230253
})
231-
expect(call1.headers, 'a cache hit on the first invocation of a prerendered route').toEqual(
254+
expect(call1.headers, 'a cache miss on the first invocation').toEqual(
232255
expect.objectContaining({
233-
'cache-status': expect.stringMatching(/"Next.js"; hit/),
256+
'cache-status': expect.stringMatching(/"Next.js"; miss/),
234257
}),
235258
)
236259
// wait to have a stale route

tests/integration/fetch-handler.test.ts

+18-5
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
invokeFunction,
1010
runPlugin,
1111
type FixtureTestContext,
12+
runPluginStep,
1213
} from '../utils/fixture.js'
1314
import {
1415
decodeBlobKey,
@@ -17,7 +18,10 @@ import {
1718
getBlobEntries,
1819
getFetchCacheKey,
1920
startMockBlobStore,
21+
changeMDate,
2022
} from '../utils/helpers.js'
23+
import { existsSync } from 'node:fs'
24+
import { join } from 'node:path'
2125

2226
// Disable the verbose logging of the lambda-local runtime
2327
getLogger().level = 'alert'
@@ -66,11 +70,20 @@ afterEach(async () => {
6670
test<FixtureTestContext>('if the fetch call is cached correctly', async (ctx) => {
6771
await createFixture('revalidate-fetch', ctx)
6872
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+
}
6983
await runPlugin(ctx)
7084

7185
// replace the build time fetch cache with our mocked hash
7286
const cacheKey = await getFetchCacheKey(new URL('/1', apiBase).href)
73-
const originalKey = '460ed46cd9a194efa197be9f2571e51b729a039d1cff9834297f416dce5ada29'
7487
const fakeKey = cacheKey
7588
const fetchEntry = await ctx.blobStore.get(encodeBlobKey(originalKey), { type: 'json' })
7689

@@ -104,10 +117,10 @@ test<FixtureTestContext>('if the fetch call is cached correctly', async (ctx) =>
104117
console.timeEnd('TimeUntilStale')
105118

106119
const post1Name = load(post1.body)('[data-testid="name"]').text()
107-
// should still get the old value
108-
expect(handlerCalled, 'should not call the API as the request should be cached').toBe(0)
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)
109122
expect(post1.statusCode).toBe(200)
110-
expect(post1Name).toBe('Under the Dome')
123+
expect(post1Name).toBe('Fake response')
111124
expect(post1.headers, 'the page should be a miss').toEqual(
112125
expect.objectContaining({
113126
'cache-status': expect.stringMatching(/"Next.js"; miss/),
@@ -127,5 +140,5 @@ test<FixtureTestContext>('if the fetch call is cached correctly', async (ctx) =>
127140
const post2Name = load(post2.body)('[data-testid="name"]').text()
128141
expect(post2.statusCode).toBe(200)
129142
expect.soft(post2Name).toBe('Fake response')
130-
expect(handlerCalled).toBe(1)
143+
expect(handlerCalled).toBe(2)
131144
})

tests/utils/helpers.ts

+16-1
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ import { BlobsServer, getDeployStore } from '@netlify/blobs'
55
import type { NetlifyPluginUtils } from '@netlify/build'
66
import IncrementalCache from 'next/dist/server/lib/incremental-cache/index.js'
77
import { Buffer } from 'node:buffer'
8-
import { mkdtemp } from 'node:fs/promises'
8+
import { mkdtemp, stat } from 'node:fs/promises'
99
import { tmpdir } from 'node:os'
1010
import { join } from 'node:path'
1111
import { assert, vi } from 'vitest'
12+
import { utimes } from 'node:fs'
1213

1314
/**
1415
* Uses next.js incremental cache to compute the same cache key for a URL that is automatically generated
@@ -115,3 +116,17 @@ export const mockBuildUtils = {
115116
assert.fail(`${message}: ${options?.error || ''}`)
116117
},
117118
} as unknown as NetlifyPluginUtils
119+
120+
export const changeMDate = async (filePath: string, newTime: number) => {
121+
const prevStat = await stat(filePath)
122+
123+
console.log('Previous Modified time', {
124+
mtime: prevStat.mtime.getTime(),
125+
})
126+
127+
utimes(filePath, new Date(newTime), new Date(newTime), () => {
128+
console.log('New Modified time', {
129+
mtime: newTime,
130+
})
131+
})
132+
}

0 commit comments

Comments
 (0)