Workers cache (#22)

* feat(auth): add Cache-Control: private to auth error responses

* feat: enable Workers Cache in wrangler config

* refactor: replace manual caches.default with Workers Cache

* fix: remove unused url variable in fetch handler

* test: update tests for Workers Cache header-based semantics

* style: apply prettier formatting

* chore(deps): bump wrangler
This commit is contained in:
2026-07-07 16:10:13 +02:00
committed by GitHub
parent d0267bab4b
commit 55c5a7ef14
6 changed files with 1290 additions and 1565 deletions
+1268 -1336
View File
File diff suppressed because it is too large Load Diff
+6 -184
View File
@@ -23,17 +23,6 @@ vi.mock('../status-page/dist/server/index.mjs', () => ({
},
}));
// Mock caches API
const mockCaches = {
default: {
match: vi.fn(),
put: vi.fn(),
},
};
// @ts-expect-error - Adding caches to global for testing
global.caches = mockCaches;
describe('worker fetch handler', () => {
async function getWorker() {
const mod = await import('./index.js');
@@ -77,50 +66,23 @@ describe('worker fetch handler', () => {
expect(response.status).toBe(401);
});
describe('caching', () => {
describe('caching headers', () => {
beforeEach(() => {
vi.clearAllMocks();
mockCaches.default.match.mockReset();
mockCaches.default.put.mockReset();
});
it('should cache response when STATUS_PUBLIC is true', async () => {
it('should set Cache-Control: public when STATUS_PUBLIC is true', async () => {
const worker = await getWorker();
const request = new Request('https://example.com/');
const envWithPublic = { ...mockEnv, STATUS_PUBLIC: 'true' };
// First request - cache miss
mockCaches.default.match.mockResolvedValueOnce(null);
const response = await worker.fetch(request, envWithPublic, mockCtx);
expect(response.status).toBe(200);
expect(response.headers.get('Cache-Control')).toBe('public, max-age=60');
expect(mockCaches.default.match).toHaveBeenCalledTimes(1);
expect(mockCaches.default.put).toHaveBeenCalledTimes(1);
});
it('should return cached response when available', async () => {
const worker = await getWorker();
const request = new Request('https://example.com/');
const envWithPublic = { ...mockEnv, STATUS_PUBLIC: 'true' };
// Cache hit
const cachedResponse = new Response('<html>Cached</html>', {
status: 200,
headers: { 'Cache-Control': 'public, max-age=60' },
});
mockCaches.default.match.mockResolvedValueOnce(cachedResponse);
const response = await worker.fetch(request, envWithPublic, mockCtx);
expect(response.status).toBe(200);
expect(response.headers.get('Cache-Control')).toBe('public, max-age=60');
expect(mockCaches.default.match).toHaveBeenCalledTimes(1);
expect(mockCaches.default.put).not.toHaveBeenCalled();
});
it('should not cache when STATUS_PUBLIC is not true', async () => {
it('should not set Cache-Control when STATUS_PUBLIC is not true', async () => {
const worker = await getWorker();
const request = new Request('https://example.com/');
const envWithoutPublic = { ...mockEnv, STATUS_PUBLIC: 'false' };
@@ -129,11 +91,9 @@ describe('worker fetch handler', () => {
expect(response.status).toBe(200);
expect(response.headers.get('Cache-Control')).toBeNull();
expect(mockCaches.default.match).not.toHaveBeenCalled();
expect(mockCaches.default.put).not.toHaveBeenCalled();
});
it('should not cache when STATUS_PUBLIC is undefined', async () => {
it('should not set Cache-Control when STATUS_PUBLIC is undefined', async () => {
const worker = await getWorker();
const request = new Request('https://example.com/');
const envWithoutPublic = { ...mockEnv };
@@ -143,11 +103,9 @@ describe('worker fetch handler', () => {
expect(response.status).toBe(200);
expect(response.headers.get('Cache-Control')).toBeNull();
expect(mockCaches.default.match).not.toHaveBeenCalled();
expect(mockCaches.default.put).not.toHaveBeenCalled();
});
it('should not cache non-GET requests', async () => {
it('should not set Cache-Control for non-GET requests', async () => {
const worker = await getWorker();
const request = new Request('https://example.com/', { method: 'POST' });
const envWithPublic = { ...mockEnv, STATUS_PUBLIC: 'true' };
@@ -156,158 +114,22 @@ describe('worker fetch handler', () => {
expect(response.status).toBe(200);
expect(response.headers.get('Cache-Control')).toBeNull();
expect(mockCaches.default.match).not.toHaveBeenCalled();
expect(mockCaches.default.put).not.toHaveBeenCalled();
});
it('should not cache error responses', async () => {
it('should not set Cache-Control for error responses', async () => {
const worker = await getWorker();
const request = new Request('https://example.com/');
const envWithPublic = { ...mockEnv, STATUS_PUBLIC: 'true' };
// Mock Astro to return error
astroFetchMock.mockResolvedValueOnce(new Response('Error', { status: 500 }));
// First request - cache miss
mockCaches.default.match.mockResolvedValueOnce(null);
const response = await worker.fetch(request, envWithPublic, mockCtx);
expect(response.status).toBe(500);
expect(response.headers.get('Cache-Control')).toBeNull();
expect(mockCaches.default.match).toHaveBeenCalledTimes(1);
expect(mockCaches.default.put).not.toHaveBeenCalled();
// Reset mock for other tests
astroFetchMock.mockResolvedValue(new Response('<html>OK</html>', { status: 200 }));
});
it('should normalize cache key by removing query parameters', async () => {
const worker = await getWorker();
const request1 = new Request('https://example.com/?t=1234567890');
const request2 = new Request('https://example.com/?cache=bust&v=2.0');
const request3 = new Request('https://example.com/');
const envWithPublic = { ...mockEnv, STATUS_PUBLIC: 'true' };
// First request with query params - cache miss
mockCaches.default.match.mockResolvedValueOnce(null);
await worker.fetch(request1, envWithPublic, mockCtx);
// Get the cache key that was used
const cacheKey1 = mockCaches.default.match.mock.calls[0][0];
expect(cacheKey1.url).toBe('https://example.com/');
// Reset mock for second request
mockCaches.default.match.mockReset();
mockCaches.default.put.mockReset();
// Second request with different query params - should use same normalized cache key
mockCaches.default.match.mockResolvedValueOnce(null);
await worker.fetch(request2, envWithPublic, mockCtx);
const cacheKey2 = mockCaches.default.match.mock.calls[0][0];
expect(cacheKey2.url).toBe('https://example.com/');
// Reset mock for third request
mockCaches.default.match.mockReset();
mockCaches.default.put.mockReset();
// Third request without query params - should use same normalized cache key
mockCaches.default.match.mockResolvedValueOnce(null);
await worker.fetch(request3, envWithPublic, mockCtx);
const cacheKey3 = mockCaches.default.match.mock.calls[0][0];
expect(cacheKey3.url).toBe('https://example.com/');
});
it('should normalize cache key by removing hash fragment', async () => {
const worker = await getWorker();
const request = new Request('https://example.com/#section1');
const envWithPublic = { ...mockEnv, STATUS_PUBLIC: 'true' };
mockCaches.default.match.mockResolvedValueOnce(null);
await worker.fetch(request, envWithPublic, mockCtx);
const cacheKey = mockCaches.default.match.mock.calls[0][0];
expect(cacheKey.url).toBe('https://example.com/');
});
it('should use normalized headers in cache key (ignore cookies)', async () => {
const worker = await getWorker();
// Request with cookies
const requestWithCookies = new Request('https://example.com/', {
headers: {
Cookie: 'session=abc123; user=john',
'User-Agent': 'Mozilla/5.0',
},
});
// Request without cookies
const requestWithoutCookies = new Request('https://example.com/', {
headers: {
'User-Agent': 'Different-Browser',
},
});
const envWithPublic = { ...mockEnv, STATUS_PUBLIC: 'true' };
// First request with cookies
mockCaches.default.match.mockResolvedValueOnce(null);
await worker.fetch(requestWithCookies, envWithPublic, mockCtx);
const cacheKey1 = mockCaches.default.match.mock.calls[0][0];
// Should not have Cookie header in cache key
expect(cacheKey1.headers.get('Cookie')).toBeNull();
// Reset mock for second request
mockCaches.default.match.mockReset();
mockCaches.default.put.mockReset();
// Second request without cookies - should use same cache key
mockCaches.default.match.mockResolvedValueOnce(null);
await worker.fetch(requestWithoutCookies, envWithPublic, mockCtx);
const cacheKey2 = mockCaches.default.match.mock.calls[0][0];
expect(cacheKey2.headers.get('Cookie')).toBeNull();
});
it('should cache hit for same normalized URL regardless of query params', async () => {
const worker = await getWorker();
const envWithPublic = { ...mockEnv, STATUS_PUBLIC: 'true' };
// First request with query params
const request1 = new Request('https://example.com/?cache=bust');
const cachedResponse = new Response('<html>Cached</html>', {
status: 200,
headers: { 'Cache-Control': 'public, max-age=60' },
});
// Cache hit for normalized URL
mockCaches.default.match.mockResolvedValueOnce(cachedResponse);
const response1 = await worker.fetch(request1, envWithPublic, mockCtx);
expect(response1.status).toBe(200);
expect(mockCaches.default.match).toHaveBeenCalledTimes(1);
// Get the cache key that was used
const cacheKey1 = mockCaches.default.match.mock.calls[0][0];
expect(cacheKey1.url).toBe('https://example.com/');
// Reset mock
mockCaches.default.match.mockReset();
// Second request with different query params - should also be cache hit
const request2 = new Request('https://example.com/?t=123456');
mockCaches.default.match.mockResolvedValueOnce(cachedResponse);
const response2 = await worker.fetch(request2, envWithPublic, mockCtx);
expect(response2.status).toBe(200);
expect(mockCaches.default.match).toHaveBeenCalledTimes(1);
const cacheKey2 = mockCaches.default.match.mock.calls[0][0];
expect(cacheKey2.url).toBe('https://example.com/');
});
});
});
+1 -42
View File
@@ -17,43 +17,11 @@ import type { Config } from './config/types.js';
const worker = {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
const authResponse = await checkAuth(request, env);
if (authResponse) {
return authResponse;
}
// Only cache GET requests when status page is public
let cacheKey: Request | undefined;
if (request.method === 'GET' && env.STATUS_PUBLIC === 'true') {
// Create normalized cache key to prevent bypass via query params, headers, or cookies
const normalizedUrl = new URL(url);
normalizedUrl.search = ''; // Remove query parameters
normalizedUrl.hash = ''; // Remove hash fragment
cacheKey = new Request(normalizedUrl.toString());
const cachedResponse = await caches.default.match(cacheKey);
if (cachedResponse) {
console.log(
JSON.stringify({
event: 'cache_hit',
url: url.toString(),
normalizedUrl: normalizedUrl.toString(),
})
);
return cachedResponse;
}
console.log(
JSON.stringify({
event: 'cache_miss',
url: url.toString(),
normalizedUrl: normalizedUrl.toString(),
})
);
}
// Try static assets first (CSS, JS, favicon, etc.)
if (env.ASSETS) {
const assetResponse = await env.ASSETS.fetch(request);
@@ -75,18 +43,9 @@ const worker = {
ctx
);
// Cache successful responses when status page is public
if (
request.method === 'GET' &&
env.STATUS_PUBLIC === 'true' &&
response.status === 200 &&
cacheKey
) {
if (request.method === 'GET' && env.STATUS_PUBLIC === 'true' && response.status === 200) {
const responseWithCache = new Response(response.body, response);
responseWithCache.headers.set('Cache-Control', 'public, max-age=60');
ctx.waitUntil(caches.default.put(cacheKey, responseWithCache.clone()));
return responseWithCache;
}
+3
View File
@@ -26,12 +26,14 @@ describe('checkAuth', () => {
const result = await checkAuth(makeRequest(), env);
expect(result).toBeInstanceOf(Response);
expect(result!.status).toBe(403);
expect(result!.headers.get('Cache-Control')).toBe('private');
});
it('returns 403 when only username is configured', async () => {
const env: AuthEnv = { STATUS_USERNAME: 'admin' };
const result = await checkAuth(makeRequest(), env);
expect(result!.status).toBe(403);
expect(result!.headers.get('Cache-Control')).toBe('private');
});
it('returns 401 when no Authorization header is sent', async () => {
@@ -39,6 +41,7 @@ describe('checkAuth', () => {
const result = await checkAuth(makeRequest(), env);
expect(result!.status).toBe(401);
expect(result!.headers.get('WWW-Authenticate')).toBe('Basic realm="Status Page"');
expect(result!.headers.get('Cache-Control')).toBe('private');
});
it('returns 401 for non-Basic auth header', async () => {
+8 -2
View File
@@ -7,7 +7,10 @@ export type AuthEnv = {
const unauthorizedResponse = (): Response =>
new Response('Unauthorized', {
status: 401,
headers: { 'WWW-Authenticate': 'Basic realm="Status Page"' },
headers: {
'WWW-Authenticate': 'Basic realm="Status Page"',
'Cache-Control': 'private',
},
});
async function timingSafeCompare(a: string, b: string): Promise<boolean> {
@@ -33,7 +36,10 @@ export async function checkAuth(request: Request, env: AuthEnv): Promise<Respons
}
if (!env.STATUS_USERNAME || !env.STATUS_PASSWORD) {
return new Response('Forbidden', { status: 403 });
return new Response('Forbidden', {
status: 403,
headers: { 'Cache-Control': 'private' },
});
}
const authHeader = request.headers.get('Authorization');
+4 -1
View File
@@ -1,9 +1,12 @@
name = "atalaya"
main = "src/index.ts"
compatibility_date = "2026-03-17"
compatibility_date = "2026-07-06"
compatibility_flags = ["nodejs_compat"]
workers_dev = false
[cache]
enabled = true
[assets]
directory = "./status-page/dist/client/"
binding = "ASSETS"