diff --git a/src/build/content/prerendered.ts b/src/build/content/prerendered.ts index e7dce0d61..03854c610 100644 --- a/src/build/content/prerendered.ts +++ b/src/build/content/prerendered.ts @@ -9,6 +9,8 @@ import { glob } from 'fast-glob' import pLimit from 'p-limit' import { satisfies } from 'semver' +import { FS_BLOBS_MANIFEST } from '../../run/constants.js' +import { type FSBlobsManifest } from '../../run/next.cjs' import { encodeBlobKey } from '../../shared/blobkey.js' import type { CachedFetchValueForMultipleVersions, @@ -158,6 +160,11 @@ export const copyPrerenderedContent = async (ctx: PluginContext): Promise }) : false + const fsBlobsManifest: FSBlobsManifest = { + fallbackPaths: [], + outputRoot: ctx.distDir, + } + await Promise.all([ ...Object.entries(manifest.routes).map( ([route, meta]): Promise => @@ -237,6 +244,8 @@ export const copyPrerenderedContent = async (ctx: PluginContext): Promise } await writeCacheEntry(key, value, lastModified, ctx) + + fsBlobsManifest.fallbackPaths.push(`${key}.html`) } }) } @@ -254,6 +263,10 @@ export const copyPrerenderedContent = async (ctx: PluginContext): Promise ) await writeCacheEntry(key, value, lastModified, ctx) } + await writeFile( + join(ctx.serverHandlerDir, FS_BLOBS_MANIFEST), + JSON.stringify(fsBlobsManifest), + ) } catch (error) { ctx.failBuild('Failed assembling prerendered content for upload', error) } diff --git a/src/run/constants.ts b/src/run/constants.ts index ebf5daaa3..c27f338b3 100644 --- a/src/run/constants.ts +++ b/src/run/constants.ts @@ -5,3 +5,4 @@ export const MODULE_DIR = fileURLToPath(new URL('.', import.meta.url)) export const PLUGIN_DIR = resolve(`${MODULE_DIR}../../..`) // a file where we store the required-server-files config object in to access during runtime export const RUN_CONFIG = 'run-config.json' +export const FS_BLOBS_MANIFEST = 'fs-blobs-manifest.json' diff --git a/src/run/handlers/request-context.cts b/src/run/handlers/request-context.cts index c54427f74..b71969327 100644 --- a/src/run/handlers/request-context.cts +++ b/src/run/handlers/request-context.cts @@ -11,7 +11,7 @@ export type RequestContext = { responseCacheGetLastModified?: number responseCacheKey?: string responseCacheTags?: string[] - usedFsRead?: boolean + usedFsReadForNonFallback?: boolean didPagesRouterOnDemandRevalidate?: boolean serverTiming?: string routeHandlerRevalidate?: NetlifyCachedRouteValue['revalidate'] diff --git a/src/run/headers.test.ts b/src/run/headers.test.ts index 6e09f8958..5afcd2e50 100644 --- a/src/run/headers.test.ts +++ b/src/run/headers.test.ts @@ -321,7 +321,7 @@ describe('headers', () => { }) }) - test('should not set any headers if "cache-control" is not set and "requestContext.usedFsRead" is not truthy', () => { + test('should not set any headers if "cache-control" is not set and "requestContext.usedFsReadForNonFallback" is not truthy', () => { const headers = new Headers() const request = new Request(defaultUrl) vi.spyOn(headers, 'set') @@ -331,15 +331,15 @@ describe('headers', () => { expect(headers.set).toHaveBeenCalledTimes(0) }) - test('should set permanent, durable "netlify-cdn-cache-control" if "cache-control" is not set and "requestContext.usedFsRead" is truthy', () => { + test('should set permanent, durable "netlify-cdn-cache-control" if "cache-control" is not set and "requestContext.usedFsReadForNonFallback" is truthy', () => { const headers = new Headers() const request = new Request(defaultUrl) vi.spyOn(headers, 'set') const requestContext = createRequestContext() - requestContext.usedFsRead = true + requestContext.usedFsReadForNonFallback = true - setCacheControlHeaders(headers, request, requestContext, true) + setCacheControlHeaders(headers, request, requestContext) expect(headers.set).toHaveBeenNthCalledWith( 1, diff --git a/src/run/headers.ts b/src/run/headers.ts index 411edf005..53811b816 100644 --- a/src/run/headers.ts +++ b/src/run/headers.ts @@ -263,7 +263,7 @@ export const setCacheControlHeaders = ( cacheControl === null && !headers.has('cdn-cache-control') && !headers.has('netlify-cdn-cache-control') && - requestContext.usedFsRead + requestContext.usedFsReadForNonFallback ) { // handle CDN Cache Control on static files headers.set('cache-control', 'public, max-age=0, must-revalidate') @@ -272,7 +272,10 @@ export const setCacheControlHeaders = ( } export const setCacheTagsHeaders = (headers: Headers, requestContext: RequestContext) => { - if (requestContext.responseCacheTags) { + if ( + requestContext.responseCacheTags && + (headers.has('cache-control') || headers.has('netlify-cdn-cache-control')) + ) { headers.set('netlify-cache-tag', requestContext.responseCacheTags.join(',')) } } diff --git a/src/run/next.cts b/src/run/next.cts index b99515e39..d90c34618 100644 --- a/src/run/next.cts +++ b/src/run/next.cts @@ -1,5 +1,5 @@ -import fs from 'fs/promises' -import { relative, resolve } from 'path' +import fs, { readFile } from 'fs/promises' +import { join, relative, resolve } from 'path' // @ts-expect-error no types installed import { patchFs } from 'fs-monkey' @@ -80,6 +80,27 @@ console.timeEnd('import next server') type FS = typeof import('fs') +export type FSBlobsManifest = { + fallbackPaths: string[] + outputRoot: string +} + +function normalizeStaticAssetPath(path: string) { + return path.startsWith('/') ? path : `/${path}` +} + +let fsBlobsManifestPromise: Promise | undefined +const getFSBlobsManifest = (): Promise => { + if (!fsBlobsManifestPromise) { + fsBlobsManifestPromise = (async () => { + const { FS_BLOBS_MANIFEST, PLUGIN_DIR } = await import('./constants.js') + return JSON.parse(await readFile(resolve(PLUGIN_DIR, FS_BLOBS_MANIFEST), 'utf-8')) + })() + } + + return fsBlobsManifestPromise +} + export async function getMockedRequestHandlers(...args: Parameters) { const tracer = getTracer() return tracer.withActiveSpan('mocked request handler', async () => { @@ -96,13 +117,17 @@ export async function getMockedRequestHandlers(...args: Parameters