refactor: 实现基于 Cache API 与 Cache-Tag 的统一公开页面缓存策略

This commit is contained in:
seahi committed 2026-09-26 19:28:27 +08:00
1 parent be0ba73edf
commit fa8c04e297
7 files changed
+287 -15

No files matched your search

+78
View File
@@ -0,0 +1,78 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { RequestEvent } from '@sveltejs/kit';
import { handle } from './hooks.server';
import { purgePublicCache } from '$lib/server/cloudflare';
vi.mock('$app/environment', () => ({ dev: false }));
vi.mock('$lib/server/access', () => ({
verifyAccessJwt: vi.fn().mockResolvedValue('admin@example.com')
}));
vi.mock('$lib/server/cloudflare', () => ({ purgePublicCache: vi.fn().mockResolvedValue(null) }));
function input(path = '/admin/posts/1', method = 'POST', authorized = true) {
const url = new URL(path, 'https://blog.example.com');
return {
event: {
url,
request: new Request(url, {
method,
headers: authorized ? { 'cf-access-jwt-assertion': 'test' } : {}
}),
route: { id: '/admin/posts/[id]' },
locals: {},
isDataRequest: false,
platform: {
env: {
CF_DOMAIN: 'https://blog.example.com',
CF_ACCESS_TEAM_DOMAIN: 'https://team.cloudflareaccess.com',
CF_ACCESS_AUD: 'test'
}
}
} as unknown as RequestEvent,
resolve: vi
.fn()
.mockResolvedValue(new Response(null, { status: 303, headers: { Location: '/admin/posts' } }))
};
}
beforeEach(() => vi.clearAllMocks());
describe('cache policy integrated with authentication and mutations', () => {
it('invalidates after a completed save and preserves the redirect', async () => {
const request = input();
const response = await handle(request);
expect(request.resolve).toHaveBeenCalledOnce();
expect(purgePublicCache).toHaveBeenCalledWith(request.event.platform);
expect(response.status).toBe(303);
expect(response.headers.get('Location')).toBe('/admin/posts');
expect(response.headers.get('Cache-Control')).toBe('no-store');
expect(response.headers.get('X-Blogflare-Cache-Purge')).toBe('accepted');
expect(request.resolve.mock.invocationCallOrder[0]).toBeLessThan(
vi.mocked(purgePublicCache).mock.invocationCallOrder[0]
);
});
it('does not purge or execute handlers for an unauthenticated save', async () => {
const request = input('/admin/posts/1', 'POST', false);
vi.spyOn(console, 'error').mockImplementation(() => {});
const response = await handle(request);
expect(response.status).toBe(401);
expect(request.resolve).not.toHaveBeenCalled();
expect(purgePublicCache).not.toHaveBeenCalled();
vi.restoreAllMocks();
});
it('does not purge for rejected writes or GET requests', async () => {
const failed = input();
failed.resolve.mockResolvedValue(new Response('invalid', { status: 400 }));
await handle(failed);
await handle(input('/admin/posts', 'GET'));
expect(purgePublicCache).not.toHaveBeenCalled();
});
it('marks purge failure without claiming the content save failed', async () => {
vi.mocked(purgePublicCache).mockResolvedValueOnce('rate limited');
const response = await handle(input());
expect(response.status).toBe(303);
expect(response.headers.get('X-Blogflare-Cache-Purge')).toBe('failed');
});
it('never caches an API authentication error', async () => {
const response = await handle(input('/api/v1/posts', 'GET', false));
expect(response.status).toBe(401);
expect(response.headers.get('Cache-Control')).toBe('no-store');
});
});
+14 -1
View File
@@ -1,3 +1,5 @@
import { applyCachePolicy, isContentMutation } from '$lib/server/cache';
import { purgePublicCache } from '$lib/server/cloudflare';
import { dev } from '$app/environment'; import { dev } from '$app/environment';
import { verifyAccessJwt } from '$lib/server/access'; import { verifyAccessJwt } from '$lib/server/access';
import type { Handle } from '@sveltejs/kit'; import type { Handle } from '@sveltejs/kit';
@@ -22,7 +24,7 @@ function accessNotConfigured(): Response {
}); });
} }
export const handle: Handle = async ({ event, resolve }) => { const authenticate: Handle = async ({ event, resolve }) => {
if (event.url.pathname.startsWith('/api/v1/')) { if (event.url.pathname.startsWith('/api/v1/')) {
const authHeader = event.request.headers.get('Authorization'); const authHeader = event.request.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) { if (!authHeader || !authHeader.startsWith('Bearer ')) {
@@ -118,3 +120,14 @@ export const handle: Handle = async ({ event, resolve }) => {
return resolve(event); return resolve(event);
}; };
export const handle: Handle = async (input) => {
const response = await authenticate(input);
const result = applyCachePolicy(input.event, response);
if (!dev && isContentMutation(input.event) && response.status < 400) {
// Await invalidation before completing the save. Failed purges do not undo saved content.
const error = await purgePublicCache(input.event.platform);
result.headers.set('X-Blogflare-Cache-Purge', error ? 'failed' : 'accepted');
}
return result;
};
+118
View File
@@ -0,0 +1,118 @@
import { describe, expect, it } from 'vitest';
import { applyCachePolicy, configuredHostname, isContentMutation, publicCacheTag } from './cache';
function event(
route = '/',
path = '/',
method = 'GET',
headers: HeadersInit = {}
): Parameters<typeof applyCachePolicy>[0] {
const url = new URL(path, 'https://blog.example.com');
return {
route: { id: route as Parameters<typeof applyCachePolicy>[0]['route']['id'] },
url,
request: new Request(url, { method, headers }),
platform: { env: { CF_DOMAIN: 'https://blog.example.com' } } as unknown as App.Platform,
isDataRequest: false
};
}
describe('public cache policy', () => {
it.each([
'/',
'/archive',
'/categories',
'/categories/[slug]',
'/tags/[tag]',
'/series',
'/series/[slug]'
])('caches list route %s for five minutes', (route) => {
const result = applyCachePolicy(event(route), new Response('public'));
expect(result.headers.get('Cache-Control')).toBe('public, max-age=0, s-maxage=300');
expect(result.headers.get('Cache-Tag')).toBe(publicCacheTag('blog.example.com'));
});
it.each(['/[slug]', '/posts/[slug]'])('caches detail route %s for an hour', (route) => {
expect(
applyCachePolicy(event(route), new Response('post')).headers.get('Cache-Control')
).toContain('s-maxage=3600');
});
it('keeps page queries distinct and tags all versions for invalidation', () => {
for (const path of [
'/tags/test?page=1',
'/tags/test?page=2',
'/tags/test?utm_source=test&page=2'
]) {
const input = event('/tags/[tag]', path);
expect(applyCachePolicy(input, new Response('list')).headers.get('Cache-Tag')).toBe(
publicCacheTag('blog.example.com')
);
expect(input.url.href).toBe(`https://blog.example.com${path}`);
}
});
it.each(['/admin', '/admin/api/images', '/api/v1/posts', '/api/slugify', '/missing'])(
'never caches private or unknown route %s',
(path) => {
expect(
applyCachePolicy(event(path, path), new Response('private')).headers.get('Cache-Control')
).toBe('no-store');
}
);
it('does not cache data responses, errors, redirects, cookies, or explicit private responses', () => {
const data = {
...event('/posts/[slug]', '/posts/test/__data.json?x-sveltekit-invalidated=11'),
isDataRequest: true
};
expect(applyCachePolicy(data, new Response('data')).headers.get('Cache-Control')).toBe(
'no-store'
);
for (const response of [
new Response('error', { status: 500 }),
new Response('missing', { status: 404 }),
new Response(null, { status: 303 }),
new Response('cookie', { headers: { 'Set-Cookie': 'session=secret' } }),
new Response('private', { headers: { 'Cache-Control': 'private' } })
]) {
expect(applyCachePolicy(event(), response).headers.get('Cache-Control')).toBe('no-store');
}
});
it('does not cache authenticated, preview, or mutating requests', () => {
for (const input of [
event('/', '/', 'POST'),
event('/', '/', 'GET', { Authorization: 'Bearer secret' }),
event('/', '/', 'GET', { Cookie: 'CF_Authorization=secret' }),
event('/', 'https://preview.pages.dev/')
]) {
expect(applyCachePolicy(input, new Response('content')).headers.get('Cache-Control')).toBe(
'no-store'
);
}
});
it('uses a hostname-specific tag and rejects malformed configuration', () => {
expect(configuredHostname('https://assets.example.com/')).toBe('assets.example.com');
for (const domain of [
'http://example.com',
'https://user:pass@example.com',
'https://example.com/path',
'https://example.com/?a=b',
undefined
])
expect(() => configuredHostname(domain)).toThrow();
expect(publicCacheTag('blog.example.com')).not.toBe(publicCacheTag('other.example.com'));
});
it('covers every content mutation entry point without purging for reads or uploads', () => {
for (const path of [
'/admin/posts/new',
'/admin/posts/1',
'/admin/pages/1',
'/admin/categories',
'/admin/tags',
'/admin/series',
'/admin/settings',
'/api/v1/posts',
'/api/v1/posts/old-slug'
])
expect(isContentMutation(event('', path, 'POST'))).toBe(true);
expect(isContentMutation(event('', '/admin/posts', 'GET'))).toBe(false);
expect(isContentMutation(event('', '/admin/api/upload', 'POST'))).toBe(false);
});
});
+74
View File
@@ -0,0 +1,74 @@
import type { RequestEvent } from '@sveltejs/kit';
export function configuredHostname(value: string | undefined): string {
if (!value) throw new Error('未配置域名');
const url = new URL(value);
if (
url.protocol !== 'https:' ||
url.username ||
url.password ||
url.port ||
url.pathname !== '/' ||
url.search ||
url.hash
)
throw new Error('域名必须是 HTTPS 根地址');
return url.hostname;
}
export function publicCacheTag(hostname: string): string {
return `blogflare-public:${hostname}`;
}
const detailRoutes = new Set(['/[slug]', '/posts/[slug]']);
const listRoutes = new Set([
'/',
'/archive',
'/categories',
'/categories/[slug]',
'/tags/[tag]',
'/series',
'/series/[slug]'
]);
export function applyCachePolicy(
event: Pick<RequestEvent, 'request' | 'route' | 'url' | 'platform' | 'isDataRequest'>,
response: Response
): Response {
const result = new Response(response.body, response);
const route = event.route.id || '';
const ttl = detailRoutes.has(route) ? 3600 : listRoutes.has(route) ? 300 : 0;
let hostname = '';
try {
hostname = configuredHostname(event.platform?.env.CF_DOMAIN);
} catch {
/* fail closed */
}
const cacheable =
!event.isDataRequest &&
ttl > 0 &&
event.url.hostname === hostname &&
['GET', 'HEAD'].includes(event.request.method) &&
response.status === 200 &&
!response.headers.has('Set-Cookie') &&
!event.request.headers.has('Authorization') &&
!/(?:^|;\s*)CF_Authorization=/.test(event.request.headers.get('cookie') || '') &&
!/\b(private|no-cache|no-store)\b/i.test(response.headers.get('Cache-Control') || '');
if (cacheable) {
result.headers.set('Cache-Control', `public, max-age=0, s-maxage=${ttl}`);
result.headers.set('Cache-Tag', publicCacheTag(hostname));
} else {
result.headers.set('Cache-Control', 'no-store');
result.headers.delete('Cache-Tag');
}
return result;
}
export function isContentMutation(event: Pick<RequestEvent, 'request' | 'url'>): boolean {
return (
['POST', 'PUT', 'PATCH', 'DELETE'].includes(event.request.method) &&
/^\/(?:admin\/(?:posts|pages|categories|tags|series|settings)|api\/v1\/(?:posts|categories|tags|series))(?:\/|$)/.test(
event.url.pathname
)
);
}
+1 -6
View File
@@ -3,12 +3,7 @@
import { createExcerpt } from '$lib/markdown/excerpt'; import { createExcerpt } from '$lib/markdown/excerpt';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ platform, setHeaders }) => { export const load: PageServerLoad = async ({ platform }) => {
// Enable CDN Edge caching for 1 hour, browser cache for 1 minute
setHeaders({
'Cache-Control': 'public, max-age=60, s-maxage=3600'
});
const db = platform?.env?.DB; const db = platform?.env?.DB;
if (!db) { if (!db) {
+1 -4
View File
@@ -4,10 +4,7 @@ import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { renderMarkdown } from '$lib/markdown/renderer'; import { renderMarkdown } from '$lib/markdown/renderer';
export const load: PageServerLoad = async ({ params, platform, parent, setHeaders }) => { export const load: PageServerLoad = async ({ params, platform, parent }) => {
setHeaders({
'Cache-Control': 'public, max-age=60, s-maxage=3600'
});
const db = platform?.env?.DB; const db = platform?.env?.DB;
const slug = params.slug; const slug = params.slug;
const { aliases } = await parent(); const { aliases } = await parent();
+1 -4
View File
@@ -18,10 +18,7 @@ interface PostRow {
series_weight: number | null; series_weight: number | null;
} }
export const load: PageServerLoad = async ({ platform, params, parent, setHeaders }) => { export const load: PageServerLoad = async ({ platform, params, parent }) => {
setHeaders({
'Cache-Control': 'public, max-age=60, s-maxage=3600'
});
const db = platform?.env?.DB; const db = platform?.env?.DB;
const { slug } = params; const { slug } = params;
const { aliases } = await parent(); const { aliases } = await parent();