Skip to content

Commit ea6af55

Browse files
committed
fix: only set permament caching header when reading html file when its not during server initialization AND when read html is Next produced fully static html
1 parent 9fe6763 commit ea6af55

File tree

4 files changed

+67
-10
lines changed

4 files changed

+67
-10
lines changed

src/build/content/static.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,21 +27,23 @@ export const copyStaticContent = async (ctx: PluginContext): Promise<void> => {
2727
})
2828

2929
const fallbacks = ctx.getFallbacks(await ctx.getPrerenderManifest())
30+
const fullyStaticPages = await ctx.getFullyStaticHtmlPages()
3031

3132
try {
3233
await mkdir(destDir, { recursive: true })
3334
await Promise.all(
3435
paths
35-
.filter((path) => !paths.includes(`${path.slice(0, -5)}.json`))
36+
.filter((path) => !path.endsWith('.json') && !paths.includes(`${path.slice(0, -5)}.json`))
3637
.map(async (path): Promise<void> => {
3738
const html = await readFile(join(srcDir, path), 'utf-8')
3839
verifyNetlifyForms(ctx, html)
3940

4041
const isFallback = fallbacks.includes(path.slice(0, -5))
42+
const isFullyStaticPage = !isFallback && fullyStaticPages.includes(path)
4143

4244
await writeFile(
4345
join(destDir, await encodeBlobKey(path)),
44-
JSON.stringify({ html, isFallback } satisfies HtmlBlob),
46+
JSON.stringify({ html, isFullyStaticPage } satisfies HtmlBlob),
4547
'utf-8',
4648
)
4749
}),

src/build/plugin-context.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
} from '@netlify/build'
1313
import type { PrerenderManifest, RoutesManifest } from 'next/dist/build/index.js'
1414
import type { MiddlewareManifest } from 'next/dist/build/webpack/plugins/middleware-plugin.js'
15+
import type { PagesManifest } from 'next/dist/build/webpack/plugins/pages-manifest-plugin.js'
1516
import type { NextConfigComplete } from 'next/dist/server/config-shared.js'
1617
import { satisfies } from 'semver'
1718

@@ -370,6 +371,35 @@ export class PluginContext {
370371
return this.#fallbacks
371372
}
372373

374+
#fullyStaticHtmlPages: string[] | null = null
375+
/**
376+
* Get an array of fully static pages router pages (no `getServerSideProps` or `getStaticProps`).
377+
* Those are being served as-is without involving CacheHandler, so we need to keep track of them
378+
* to make sure we apply permanent caching headers for responses that use them.
379+
*/
380+
async getFullyStaticHtmlPages(): Promise<string[]> {
381+
if (!this.#fullyStaticHtmlPages) {
382+
const pagesManifest = JSON.parse(
383+
await readFile(join(this.publishDir, 'server/pages-manifest.json'), 'utf-8'),
384+
) as PagesManifest
385+
386+
this.#fullyStaticHtmlPages = Object.values(pagesManifest)
387+
.filter(
388+
(filePath) =>
389+
// Limit handling to pages router files (App Router pages should not be included in pages-manifest.json
390+
// as they have their own app-paths-manifest.json)
391+
filePath.startsWith('pages/') &&
392+
// Fully static pages will have entries in the pages-manifest.json pointing to .html files.
393+
// Pages with data fetching exports will point to .js files.
394+
filePath.endsWith('.html'),
395+
)
396+
// values will be prefixed with `pages/`, so removing it here for consistency with other methods
397+
// like `getFallbacks` that return the route without the prefix
398+
.map((filePath) => relative('pages', filePath))
399+
}
400+
return this.#fullyStaticHtmlPages
401+
}
402+
373403
/** Fails a build with a message and an optional error */
374404
failBuild(message: string, error?: unknown): never {
375405
return this.utils.build.failBuild(message, error instanceof Error ? { error } : undefined)

src/run/next.cts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import fs from 'fs/promises'
2-
import { relative, resolve } from 'path'
1+
import { AsyncLocalStorage } from 'node:async_hooks'
2+
import fs from 'node:fs/promises'
3+
import { relative, resolve } from 'node:path'
34

45
// @ts-expect-error no types installed
56
import { patchFs } from 'fs-monkey'
@@ -79,6 +80,13 @@ ResponseCache.prototype.get = function get(...getArgs: unknown[]) {
7980
type FS = typeof import('fs')
8081

8182
export async function getMockedRequestHandler(...args: Parameters<typeof getRequestHandlers>) {
83+
const initContext = { initializingServer: true }
84+
/**
85+
* Using async local storage to identify operations happening as part of server initialization
86+
* and not part of handling of current request.
87+
*/
88+
const initAsyncLocalStorage = new AsyncLocalStorage<typeof initContext>()
89+
8290
const tracer = getTracer()
8391
return tracer.withActiveSpan('mocked request handler', async () => {
8492
const ofs = { ...fs }
@@ -96,9 +104,21 @@ export async function getMockedRequestHandler(...args: Parameters<typeof getRequ
96104
const relPath = relative(resolve('.next/server/pages'), path)
97105
const file = await cacheStore.get<HtmlBlob>(relPath, 'staticHtml.get')
98106
if (file !== null) {
99-
if (!file.isFallback) {
107+
if (file.isFullyStaticPage) {
100108
const requestContext = getRequestContext()
101-
if (requestContext) {
109+
// On server initialization Next.js attempt to preload all pages
110+
// which might result in reading .html files from the file system
111+
// for fully static pages. We don't want to capture those cases.
112+
// Note that Next.js does NOT cache read html files so on actual requests
113+
// that those will be served, it will read those AGAIN and then we do
114+
// want to capture fact of reading them.
115+
const { initializingServer } = initAsyncLocalStorage.getStore() ?? {}
116+
if (!initializingServer && requestContext) {
117+
console.log('setting usedFsReadForNonFallback to true', {
118+
requestContext,
119+
initContext: initAsyncLocalStorage.getStore(),
120+
initializingServer,
121+
})
102122
requestContext.usedFsReadForNonFallback = true
103123
}
104124
}
@@ -120,7 +140,12 @@ export async function getMockedRequestHandler(...args: Parameters<typeof getRequ
120140
require('fs').promises,
121141
)
122142

123-
const requestHandlers = await getRequestHandlers(...args)
143+
const requestHandlers = await initAsyncLocalStorage.run(initContext, async () => {
144+
// we need to await getRequestHandlers(...) promise in this callback to ensure that initAsyncLocalStorage
145+
// is available in async / background work
146+
return await getRequestHandlers(...args)
147+
})
148+
124149
// depending on Next.js version requestHandlers might be an array of object
125150
// see https://github.com/vercel/next.js/commit/08e7410f15706379994b54c3195d674909a8d533#diff-37243d614f1f5d3f7ea50bbf2af263f6b1a9a4f70e84427977781e07b02f57f1R742
126151
return Array.isArray(requestHandlers) ? requestHandlers[0] : requestHandlers.requestHandler

src/shared/blob-types.cts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ export type TagManifest = { revalidatedAt: number }
44

55
export type HtmlBlob = {
66
html: string
7-
isFallback: boolean
7+
isFullyStaticPage: boolean
88
}
99

1010
export type BlobType = NetlifyCacheHandlerValue | TagManifest | HtmlBlob
@@ -24,9 +24,9 @@ export const isHtmlBlob = (value: BlobType): value is HtmlBlob => {
2424
typeof value === 'object' &&
2525
value !== null &&
2626
'html' in value &&
27-
'isFallback' in value &&
27+
'isFullyStaticPage' in value &&
2828
typeof value.html === 'string' &&
29-
typeof value.isFallback === 'boolean' &&
29+
typeof value.isFullyStaticPage === 'boolean' &&
3030
Object.keys(value).length === 2
3131
)
3232
}

0 commit comments

Comments
 (0)