refactor: 实现基于 Cache API 与 Cache-Tag 的统一公开页面缓存策略
This commit is contained in:
1 parent
be0ba73edf
commit
fa8c04e297
7 files changed
+287
-15
No files matched your search
@@ -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
@@ -1,3 +1,5 @@
|
||||
import { applyCachePolicy, isContentMutation } from '$lib/server/cache';
|
||||
import { purgePublicCache } from '$lib/server/cloudflare';
|
||||
import { dev } from '$app/environment';
|
||||
import { verifyAccessJwt } from '$lib/server/access';
|
||||
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/')) {
|
||||
const authHeader = event.request.headers.get('Authorization');
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
@@ -118,3 +120,14 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -3,12 +3,7 @@
|
||||
import { createExcerpt } from '$lib/markdown/excerpt';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ platform, setHeaders }) => {
|
||||
// Enable CDN Edge caching for 1 hour, browser cache for 1 minute
|
||||
setHeaders({
|
||||
'Cache-Control': 'public, max-age=60, s-maxage=3600'
|
||||
});
|
||||
|
||||
export const load: PageServerLoad = async ({ platform }) => {
|
||||
const db = platform?.env?.DB;
|
||||
|
||||
if (!db) {
|
||||
|
||||
@@ -4,10 +4,7 @@ import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { renderMarkdown } from '$lib/markdown/renderer';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, platform, parent, setHeaders }) => {
|
||||
setHeaders({
|
||||
'Cache-Control': 'public, max-age=60, s-maxage=3600'
|
||||
});
|
||||
export const load: PageServerLoad = async ({ params, platform, parent }) => {
|
||||
const db = platform?.env?.DB;
|
||||
const slug = params.slug;
|
||||
const { aliases } = await parent();
|
||||
|
||||
@@ -18,10 +18,7 @@ interface PostRow {
|
||||
series_weight: number | null;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ platform, params, parent, setHeaders }) => {
|
||||
setHeaders({
|
||||
'Cache-Control': 'public, max-age=60, s-maxage=3600'
|
||||
});
|
||||
export const load: PageServerLoad = async ({ platform, params, parent }) => {
|
||||
const db = platform?.env?.DB;
|
||||
const { slug } = params;
|
||||
const { aliases } = await parent();
|
||||
|
||||
Reference in new issue
Block a user