diff --git a/astro.config.mjs b/astro.config.mjs index 5389a12c..720fb941 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -48,6 +48,18 @@ export default defineConfig({ }, env: { schema: { + CACHE_CDN_MAX_AGE: envField.number({ + access: 'public', + context: 'client', + default: 300, + optional: true + }), + CACHE_STALE_WHILE_REVALIDATE: envField.number({ + access: 'public', + context: 'client', + default: 604800, + optional: true + }), DISABLE_CACHE: envField.boolean({ access: 'public', context: 'client', diff --git a/src/env.d.ts b/src/env.d.ts index 7273039b..d6ca87a0 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -9,6 +9,8 @@ declare module '@performant-software/shared-components'; declare module 'underscore'; declare module "astro:env/client" { + export const CACHE_CDN_MAX_AGE: number; + export const CACHE_STALE_WHILE_REVALIDATE: number; export const DISABLE_CACHE: boolean; export const PRELOAD_MAP: boolean; export const STATIC_BUILD: boolean; diff --git a/src/utils/url.ts b/src/utils/url.ts index 962e0602..e7805c2a 100644 --- a/src/utils/url.ts +++ b/src/utils/url.ts @@ -1,4 +1,4 @@ -import { DISABLE_CACHE } from 'astro:env/client'; +import { CACHE_CDN_MAX_AGE, CACHE_STALE_WHILE_REVALIDATE, DISABLE_CACHE } from 'astro:env/client'; import _ from 'underscore'; interface SearchParams { @@ -64,6 +64,12 @@ export const parseSearchParams = (urlString: string): SearchParams => { return params; }; +/** + * The cache tag set on every cached HTML response. A TinaCMS publish purges this tag + * (see tina/git-provider.ts), so sites can safely run a long CACHE_CDN_MAX_AGE. + */ +export const CACHE_TAG_CONTENT = 'tina-content'; + /** * Sets the cache control on the passed headers. * @@ -77,7 +83,14 @@ export const setCacheControl = (headers: Headers): void => { // Tell the browser to always check the freshness of the cache headers.set('Cache-Control', 'public, max-age=0, must-revalidate'); - // Tell Netlify's CDN to treat it as fresh for 5 minutes, then for up to a week return a stale version - // while it revalidates. Use Durable Cache to minimize the need for serverless function calls. - headers.set('Netlify-CDN-Cache-Control', 'public, durable, s-maxage=300, stale-while-revalidate=604800'); + // Tell Netlify's CDN to treat it as fresh for CACHE_CDN_MAX_AGE seconds (default 5 minutes), then + // return a stale version while it revalidates for up to CACHE_STALE_WHILE_REVALIDATE seconds + // (default 1 week). Use Durable Cache to minimize the need for serverless function calls. + headers.set( + 'Netlify-CDN-Cache-Control', + `public, durable, s-maxage=${CACHE_CDN_MAX_AGE}, stale-while-revalidate=${CACHE_STALE_WHILE_REVALIDATE}` + ); + + // Tag the response so a TinaCMS publish can purge it immediately, regardless of TTL. + headers.set('Netlify-Cache-Tag', CACHE_TAG_CONTENT); }; diff --git a/test/cacheControl.test.ts b/test/cacheControl.test.ts new file mode 100644 index 00000000..5b7947de --- /dev/null +++ b/test/cacheControl.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const env = { + CACHE_CDN_MAX_AGE: 300, + CACHE_STALE_WHILE_REVALIDATE: 604800, + DISABLE_CACHE: false +}; + +vi.mock('astro:env/client', () => ({ + get CACHE_CDN_MAX_AGE() { return env.CACHE_CDN_MAX_AGE; }, + get CACHE_STALE_WHILE_REVALIDATE() { return env.CACHE_STALE_WHILE_REVALIDATE; }, + get DISABLE_CACHE() { return env.DISABLE_CACHE; } +})); + +import { CACHE_TAG_CONTENT, setCacheControl } from '@utils/url'; + +describe('setCacheControl', () => { + beforeEach(() => { + env.CACHE_CDN_MAX_AGE = 300; + env.CACHE_STALE_WHILE_REVALIDATE = 604800; + env.DISABLE_CACHE = false; + }); + + it('sets the default headers, byte-identical to the pre-configurable values', () => { + const headers = new Headers(); + setCacheControl(headers); + + expect(headers.get('Cache-Control')).toBe('public, max-age=0, must-revalidate'); + expect(headers.get('Netlify-CDN-Cache-Control')) + .toBe('public, durable, s-maxage=300, stale-while-revalidate=604800'); + }); + + it('tags the response for publish-triggered purging', () => { + const headers = new Headers(); + setCacheControl(headers); + + expect(headers.get('Netlify-Cache-Tag')).toBe(CACHE_TAG_CONTENT); + }); + + it('builds the CDN header from the CACHE_* env values', () => { + env.CACHE_CDN_MAX_AGE = 86400; + env.CACHE_STALE_WHILE_REVALIDATE = 3600; + + const headers = new Headers(); + setCacheControl(headers); + + expect(headers.get('Netlify-CDN-Cache-Control')) + .toBe('public, durable, s-maxage=86400, stale-while-revalidate=3600'); + expect(headers.get('Cache-Control')).toBe('public, max-age=0, must-revalidate'); + }); + + it('sets nothing when DISABLE_CACHE is on', () => { + env.DISABLE_CACHE = true; + + const headers = new Headers(); + setCacheControl(headers); + + expect(headers.get('Cache-Control')).toBeNull(); + expect(headers.get('Netlify-CDN-Cache-Control')).toBeNull(); + expect(headers.get('Netlify-Cache-Tag')).toBeNull(); + }); +}); diff --git a/tina/git-provider.ts b/tina/git-provider.ts index 306a223e..53d99b20 100644 --- a/tina/git-provider.ts +++ b/tina/git-provider.ts @@ -1,7 +1,22 @@ +import { purgeCache } from '@netlify/functions' import { Octokit } from '@octokit/rest' import { Base64 } from 'js-base64' import type { GitProvider } from '@tinacms/datalayer' +// Must match CACHE_TAG_CONTENT in src/utils/url.ts (not importable here — it uses astro:env). +const CACHE_TAG_CONTENT = 'tina-content' + +// Purge every CDN-cached page tagged with the content tag so a publish is visible +// immediately, regardless of the site's CACHE_CDN_MAX_AGE. A failed purge must never +// fail the content save — stale pages still expire via the normal TTL. +const purgeContentCache = async () => { + try { + await purgeCache({ tags: [CACHE_TAG_CONTENT] }) + } catch (e) { + console.error('Cache purge failed (content saved; pages refresh on TTL expiry):', e) + } +} + type OctokitOptions = ConstructorParameters[0] export interface GitHubProviderOptions { owner: string @@ -58,6 +73,8 @@ export class GitHubProvider implements GitProvider { branch: this.branch, sha, }) + + await purgeContentCache() } async onDelete(key: string) { @@ -85,6 +102,8 @@ export class GitHubProvider implements GitProvider { branch: this.branch, sha, }) + + await purgeContentCache() } else { throw new Error( `Could not find file ${path} in repo ${this.owner}/${this.repo}`