Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
21 changes: 17 additions & 4 deletions src/utils/url.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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.
*
Expand All @@ -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);
};
62 changes: 62 additions & 0 deletions test/cacheControl.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
19 changes: 19 additions & 0 deletions tina/git-provider.ts
Original file line number Diff line number Diff line change
@@ -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<typeof Octokit>[0]
export interface GitHubProviderOptions {
owner: string
Expand Down Expand Up @@ -58,6 +73,8 @@ export class GitHubProvider implements GitProvider {
branch: this.branch,
sha,
})

await purgeContentCache()
}

async onDelete(key: string) {
Expand Down Expand Up @@ -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}`
Expand Down