diff --git a/app/utils/getCacheAnalysis.test.ts b/app/utils/getCacheAnalysis.test.ts index b8648d4..12dfb6c 100644 --- a/app/utils/getCacheAnalysis.test.ts +++ b/app/utils/getCacheAnalysis.test.ts @@ -16,7 +16,7 @@ describe('getCacheAnalysis', () => { expect(result).toHaveProperty('servedBy') expect(result).toHaveProperty('cacheStatus') expect(result).toHaveProperty('cacheControl') - expect(result.servedBy.source).toBe(ServedBySource.CDN) + expect(result.servedBy.source).toBe(ServedBySource.CdnEdge) }) it('integrates Cache-Status parsing correctly', () => { @@ -66,6 +66,23 @@ describe('getCacheAnalysis', () => { expect(() => getCacheAnalysis(headers, now)).toThrow('Could not determine who served the request') }) + + it('returns CdnOrigin when Netlify Edge has a cache miss', () => { + const headers = { + 'cache-status': '"Netlify Edge"; fwd=miss', + 'debug-x-bb-host-id': 'cdn-glo-aws-cmh-57', + } + const now = Date.now() + + const result = getCacheAnalysis(headers, now) + + expect(result.servedBy.source).toBe(ServedBySource.CdnOrigin) + expect(result.servedBy.cdnNodes).toBe('cdn-glo-aws-cmh-57') + expect(result.cacheStatus).toHaveLength(1) + expect(result.cacheStatus[0]?.cacheName).toBe('Netlify Edge') + expect(result.cacheStatus[0]?.parameters.hit).toBe(false) + expect(result.cacheStatus[0]?.parameters.fwd).toBe('miss') + }) }) describe('parseCacheStatus', () => { diff --git a/app/utils/getServedBy.test.ts b/app/utils/getServedBy.test.ts index d0c82fe..7654e93 100644 --- a/app/utils/getServedBy.test.ts +++ b/app/utils/getServedBy.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect } from 'vitest' import { getServedBy, ServedBySource, type ParsedCacheStatusEntry } from './getServedBy' describe('getServedBy', () => { - it('returns CDN when Netlify Edge cache has a hit', () => { + it('returns CdnEdge when Netlify Edge cache has a hit', () => { const headers = new Headers({ 'Debug-X-BB-Host-Id': 'node1.example.com', }) @@ -20,11 +20,11 @@ describe('getServedBy', () => { const result = getServedBy(headers, cacheStatus) - expect(result.source).toBe(ServedBySource.CDN) + expect(result.source).toBe(ServedBySource.CdnEdge) expect(result.cdnNodes).toBe('node1.example.com') }) - it('prioritizes CDN hit over durable cache hit when both are present', () => { + it('prioritizes CdnEdge hit over durable cache hit when both are present', () => { const headers = new Headers({ 'Debug-X-BB-Host-Id': 'node1.example.com', }) @@ -49,7 +49,7 @@ describe('getServedBy', () => { const result = getServedBy(headers, cacheStatus) - expect(result.source).toBe(ServedBySource.CDN) + expect(result.source).toBe(ServedBySource.CdnEdge) }) it('returns DurableCache when Netlify Durable cache has a hit', () => { @@ -57,6 +57,15 @@ describe('getServedBy', () => { 'Debug-X-BB-Host-Id': 'node1.example.com', }) const cacheStatus: ParsedCacheStatusEntry[] = [ + { + cacheName: 'Netlify Edge', + parameters: { + hit: false, + fwd: 'miss', + stored: false, + collapsed: false, + }, + }, { cacheName: 'Netlify Durable', parameters: { @@ -77,7 +86,26 @@ describe('getServedBy', () => { 'Debug-X-NF-Function-Type': 'edge', 'Debug-X-BB-Host-Id': 'node1.example.com', }) - const cacheStatus: ParsedCacheStatusEntry[] = [] + const cacheStatus: ParsedCacheStatusEntry[] = [ + { + cacheName: 'Netlify Edge', + parameters: { + hit: false, + fwd: 'miss', + stored: false, + collapsed: false, + }, + }, + { + cacheName: 'Netlify Durable', + parameters: { + hit: false, + fwd: 'miss', + stored: false, + collapsed: false, + }, + }, + ] const result = getServedBy(headers, cacheStatus) @@ -90,7 +118,26 @@ describe('getServedBy', () => { 'Debug-X-NF-Edge-Functions': 'middleware', 'Debug-X-BB-Host-Id': 'node1.example.com', }) - const cacheStatus: ParsedCacheStatusEntry[] = [] + const cacheStatus: ParsedCacheStatusEntry[] = [ + { + cacheName: 'Netlify Edge', + parameters: { + hit: false, + fwd: 'miss', + stored: false, + collapsed: false, + }, + }, + { + cacheName: 'Netlify Durable', + parameters: { + hit: false, + fwd: 'miss', + stored: false, + collapsed: false, + }, + }, + ] const result = getServedBy(headers, cacheStatus) @@ -102,7 +149,26 @@ describe('getServedBy', () => { 'Debug-X-NF-Edge-Functions': 'middleware', 'Debug-X-BB-Host-Id': 'node1.example.com', }) - const cacheStatus: ParsedCacheStatusEntry[] = [] + const cacheStatus: ParsedCacheStatusEntry[] = [ + { + cacheName: 'Netlify Edge', + parameters: { + hit: false, + fwd: 'miss', + stored: false, + collapsed: false, + }, + }, + { + cacheName: 'Netlify Durable', + parameters: { + hit: false, + fwd: 'miss', + stored: false, + collapsed: false, + }, + }, + ] const result = getServedBy(headers, cacheStatus) @@ -156,7 +222,60 @@ describe('getServedBy', () => { ) }) - it('ignores cache entries without hits', () => { + it('returns CdnOrigin when Netlify Edge cache has a miss', () => { + const headers = new Headers({ + 'Debug-X-BB-Host-Id': 'node1.example.com', + }) + const cacheStatus: ParsedCacheStatusEntry[] = [ + { + cacheName: 'Netlify Edge', + parameters: { + hit: false, + fwd: 'miss', + stored: false, + collapsed: false, + }, + }, + ] + + const result = getServedBy(headers, cacheStatus) + + expect(result.source).toBe(ServedBySource.CdnOrigin) + expect(result.cdnNodes).toBe('node1.example.com') + }) + + it('returns CdnOrigin when Netlify Edge miss and Netlify Durable miss (no cache hits)', () => { + const headers = new Headers({ + 'Debug-X-BB-Host-Id': 'node1.example.com', + }) + const cacheStatus: ParsedCacheStatusEntry[] = [ + { + cacheName: 'Netlify Edge', + parameters: { + hit: false, + fwd: 'miss', + stored: false, + collapsed: false, + }, + }, + { + cacheName: 'Netlify Durable', + parameters: { + hit: false, + fwd: 'miss', + stored: false, + collapsed: false, + }, + }, + ] + + const result = getServedBy(headers, cacheStatus) + + expect(result.source).toBe(ServedBySource.CdnOrigin) + expect(result.cdnNodes).toBe('node1.example.com') + }) + + it('ignores cache entries without hits and picks first with hit', () => { const headers = new Headers({ 'Debug-X-BB-Host-Id': 'node1.example.com', }) diff --git a/app/utils/getServedBy.ts b/app/utils/getServedBy.ts index d3e212b..6c7c625 100644 --- a/app/utils/getServedBy.ts +++ b/app/utils/getServedBy.ts @@ -1,5 +1,6 @@ export enum ServedBySource { - CDN = 'CDN', + CdnEdge = 'CDN edge', + CdnOrigin = 'CDN origin', DurableCache = 'Durable Cache', Function = 'Function', EdgeFunction = 'Edge Function', @@ -40,13 +41,15 @@ const getServedBySource = ( // So, the first cache hit (starting from the user) is the one that served the request. // But we don't quite want to return exactly the same concept of "caches" as in `Cache-Status`, so // we need a bit of extra logic to map to other sources. + + // First, check for cache hits for (const { cacheName, parameters: { hit }, } of cacheStatus) { if (!hit) continue - if (cacheName === 'Netlify Edge') return ServedBySource.CDN + if (cacheName === 'Netlify Edge') return ServedBySource.CdnEdge if (cacheName === 'Netlify Durable') return ServedBySource.DurableCache } @@ -58,6 +61,21 @@ const getServedBySource = ( if (cacheHeaders.has('Debug-X-NF-Edge-Functions')) return ServedBySource.EdgeFunction + // Check for the specific case of Netlify Edge miss with no subsequent cache hits - this handles + // the weird Netlify Cache-Status behavior where a miss on the CDN edge means the request was + // forwarded to CDN origin. According to Netlify's cache behavior, when there's a miss + // on "Netlify Edge" and no hits in subsequent caches, the request gets served by the CDN origin. + const netlifyEdgeMiss = cacheStatus.find( + entry => entry.cacheName === 'Netlify Edge' && !entry.parameters.hit + ) + const hasSubsequentCacheHits = cacheStatus.some( + entry => entry.cacheName !== 'Netlify Edge' && entry.parameters.hit + ) + + if (netlifyEdgeMiss && !hasSubsequentCacheHits) { + return ServedBySource.CdnOrigin + } + throw new Error( `Could not determine who served the request. Cache status: ${cacheStatus}`, )