mirror of
https://github.com/dcarrillo/atalaya.git
synced 2026-07-20 23:54:44 +00:00
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:
Generated
+1268
-1336
File diff suppressed because it is too large
Load Diff
+6
-184
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user