diff --git a/app/utils/cache-control.test.ts b/app/utils/cache-control.test.ts new file mode 100644 index 0000000..a469fe5 --- /dev/null +++ b/app/utils/cache-control.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from 'vitest' + +import { parse, CacheControl } from './cache-control' + +describe('parse', () => { + it('parses max-age directive from cache control header', () => { + const result = parse('max-age=3600') + + expect(result.maxAge).toBe(3600) + }) + + it('parses public directive from cache control header', () => { + expect(parse('public, max-age=5')).toHaveProperty('public', true) + expect(parse('max-age=5, public')).toHaveProperty('public', true) + expect(parse('max-age=5')).toHaveProperty('public', false) + }) + + it('parses s-maxage directive from cache control header', () => { + const result = parse('s-maxage=7200') + + expect(result.sharedMaxAge).toBe(7200) + }) + + it('parses must-revalidate directive from cache control header', () => { + expect(parse('public, must-revalidate')).toHaveProperty('mustRevalidate', true) + expect(parse('must-revalidate, public')).toHaveProperty('mustRevalidate', true) + expect(parse('max-age=5, public')).toHaveProperty('mustRevalidate', false) + }) + + it('parses immutable directive from cache control header', () => { + expect(parse('public, immutable')).toHaveProperty('immutable', true) + expect(parse('immutable, public')).toHaveProperty('immutable', true) + expect(parse('max-age=5, public')).toHaveProperty('immutable', false) + }) + + it('parses private directive from cache control header', () => { + expect(parse('max-age=5, private')).toHaveProperty('private', true) + expect(parse('private, max-age=5')).toHaveProperty('private', true) + expect(parse('max-age=5, public')).toHaveProperty('private', false) + }) + + it('parses no-store directive from cache control header', () => { + expect(parse('max-age=5, no-store')).toHaveProperty('noStore', true) + expect(parse('no-store, max-age=5')).toHaveProperty('noStore', true) + expect(parse('max-age=5, public')).toHaveProperty('noStore', false) + }) + + it('parses no-cache directive from cache control header', () => { + expect(parse('max-age=5, no-cache')).toHaveProperty('noCache', true) + expect(parse('no-cache, max-age=5')).toHaveProperty('noCache', true) + expect(parse('max-age=5, public')).toHaveProperty('noCache', false) + }) + + it('returns null values when header is empty', () => { + const result = parse('') + + expect(result.maxAge).toBe(null) + expect(result.public).toBe(null) + }) + + it('returns null values when header is null', () => { + const result = parse(null) + + expect(result.maxAge).toBe(null) + expect(result.public).toBe(null) + }) + + it('returns null values when header is undefined', () => { + const result = parse(undefined) + + expect(result.maxAge).toBe(null) + expect(result.public).toBe(null) + }) + + it('can be instantiated and used directly', () => { + const cc = new CacheControl() + const result = cc.parse('max-age=1800') + + expect(result.maxAge).toBe(1800) + }) + + it('parses extension directives without values', () => { + const result = parse('max-age=3600, durable') + + expect(result.maxAge).toBe(3600) + expect(result.extensions).toHaveProperty('durable', null) + }) + + it('parses extension directives with values', () => { + const result = parse('max-age=3600, fishiness=42') + + expect(result.maxAge).toBe(3600) + expect(result.extensions).toHaveProperty('fishiness', '42') + }) +}) diff --git a/app/utils/getCacheAnalysis.test.ts b/app/utils/getCacheAnalysis.test.ts index f24f9d9..b8648d4 100644 --- a/app/utils/getCacheAnalysis.test.ts +++ b/app/utils/getCacheAnalysis.test.ts @@ -1,93 +1,165 @@ import { describe, it, expect } from 'vitest' -import { getTimeToLive } from './getCacheAnalysis' +import getCacheAnalysis, { parseCacheStatus } from './getCacheAnalysis' +import { ServedBySource } from './getServedBy' describe('getCacheAnalysis', () => { - it.todo('works') -}) + it('returns cache analysis structure with basic CDN headers', () => { + const headers = { + 'Cache-Status': '"Netlify Edge"; hit', + 'Debug-X-BB-Host-Id': 'node1.example.com', + } + const now = Date.now() + + const result = getCacheAnalysis(headers, now) + + expect(result).toHaveProperty('servedBy') + expect(result).toHaveProperty('cacheStatus') + expect(result).toHaveProperty('cacheControl') + expect(result.servedBy.source).toBe(ServedBySource.CDN) + }) -describe('getTimeToLive', () => { - it('returns the diff in seconds from `maxAge` to `age` if they are both defined', () => { - const age = 10 - const date = undefined - const expiresAt = undefined - const maxAge = 25 + it('integrates Cache-Status parsing correctly', () => { + const headers = { + 'Cache-Status': '"Next.js"; hit, "Netlify Edge"; hit', + 'Debug-X-BB-Host-Id': 'node1.example.com', + } const now = Date.now() - expect(getTimeToLive(age, date, expiresAt, maxAge, now)).toBe(15) + const result = getCacheAnalysis(headers, now) + + expect(result.cacheStatus).toHaveLength(2) + expect(result.cacheStatus[0]?.cacheName).toBe('Netlify Edge') + expect(result.cacheStatus[1]?.cacheName).toBe('Next.js') }) - it('returns the diff in seconds from `maxAge` to `age` if they are both defined as well as `date`', () => { - const age = 10 - const date = new Date(999_999_999) - const expiresAt = undefined - const maxAge = 25 + it('integrates cache control parsing correctly', () => { + const headers = { + 'Cache-Control': 'max-age=3600', + 'Cache-Status': '"Netlify Edge"; hit', + 'Debug-X-BB-Host-Id': 'node1.example.com', + } const now = Date.now() - expect(getTimeToLive(age, date, expiresAt, maxAge, now)).toBe(15) + const result = getCacheAnalysis(headers, now) + + expect(result.cacheControl.isCacheable).toBe(true) + expect(typeof result.cacheControl.ttl).toBe('number') }) - it('returns the diff in seconds from `maxAge` to `now - date` if `maxAge` and `date` are defined but not `age`', () => { - const age = undefined - const date = new Date(1_000_000) - const expiresAt = undefined - const maxAge = 25 - const now = new Date(1_000_000 + 10_000).getTime() + it('integrates served by determination correctly', () => { + const headers = { + 'Cache-Status': '"Netlify Durable"; hit', + 'Debug-X-BB-Host-Id': 'node1.example.com', + } + const now = Date.now() + + const result = getCacheAnalysis(headers, now) - expect(getTimeToLive(age, date, expiresAt, maxAge, now)).toBe(15) + expect(result.servedBy.source).toBe(ServedBySource.DurableCache) + expect(result.servedBy.cdnNodes).toBe('node1.example.com') }) - it('returns the diff in seconds from `maxAge` to `now` if `maxAge` is defined but neither `age` nor `date`', () => { - const age = undefined - const date = undefined - const expiresAt = undefined - const maxAge = 25 - const now = new Date(1_000_000 + 10_000).getTime() + it('handles empty headers gracefully', () => { + const headers = {} + const now = Date.now() - expect(getTimeToLive(age, date, expiresAt, maxAge, now)).toBe(25) + expect(() => getCacheAnalysis(headers, now)).toThrow('Could not determine who served the request') }) +}) + +describe('parseCacheStatus', () => { + it('parses single cache status entry with hit parameter', () => { + const cacheStatus = '"Netlify Edge"; hit' + + const result = parseCacheStatus(cacheStatus) + + expect(result).toEqual([ + { + cacheName: 'Netlify Edge', + parameters: { + 'hit': true, + 'fwd': undefined, + 'fwd-status': undefined, + 'ttl': undefined, + 'stored': false, + 'collapsed': false, + 'key': undefined, + 'detail': undefined, + }, + }, + ]) + }) + + it('parses multiple cache status entries with different parameters', () => { + const cacheStatus = '"Next.js"; hit, "Netlify Durable"; fwd=miss; stored, "Netlify Edge"; fwd=miss' - it('returns the diff in seconds from `expiresAt` to `now - age` if `maxAge` and `date` are not defined and `expiresAt` and `age` are defined', () => { - const age = 10 - const date = undefined - const expiresAt = new Date(1_000_000 + 25_000) - const maxAge = null - const now = new Date(1_000_000 + 10_000).getTime() + const result = parseCacheStatus(cacheStatus) - expect(getTimeToLive(age, date, expiresAt, maxAge, now)).toBe(15) + expect(result).toHaveLength(3) + expect(result[0]?.cacheName).toBe('Netlify Edge') // Should be reversed per spec + expect(result[1]?.cacheName).toBe('Netlify Durable') + expect(result[2]?.cacheName).toBe('Next.js') + expect(result[0]?.parameters.fwd).toBe('miss') + expect(result[1]?.parameters.stored).toBe(true) + expect(result[2]?.parameters.hit).toBe(true) }) - // FIXME(serhalp) Real bug. Fix logic. It's depending on `now` when given two absolute dates... - it.fails( - 'returns the diff in seconds from `expiresAt` to `date` if `maxAge` and `age` are not defined and `expiresAt` and `date` are defined', - () => { - const age = undefined - const date = new Date(1_000_000) - const expiresAt = new Date(1_000_000 + 15_000) - const maxAge = null - const now = Date.now() - - expect(getTimeToLive(age, date, expiresAt, maxAge, now)).toBe(15) - }, - ) - - it('returns the diff in seconds from `expiresAt` to `now` if `maxAge`, `age`, and `date` are not defined and `expiresAt` is defined', () => { - const age = undefined - const date = undefined - const expiresAt = new Date(1_000_000 + 15_000) - const maxAge = null - const now = new Date(1_000_000).getTime() - - expect(getTimeToLive(age, date, expiresAt, maxAge, now)).toBe(15) + it('parses cache status with numeric parameters', () => { + const cacheStatus = '"Netlify Edge"; hit; fwd-status=200; ttl=3600' + + const result = parseCacheStatus(cacheStatus) + + expect(result[0]?.parameters['fwd-status']).toBe(200) + expect(result[0]?.parameters.ttl).toBe(3600) }) - it('returns `undefined` if `maxAge` and `expiresAt` are not defined', () => { - const age = 10 - const date = new Date() - const expiresAt = undefined - const maxAge = null - const now = Date.now() + it('parses cache status with string parameters', () => { + const cacheStatus = '"Netlify Edge"; hit; key=cache-key-123; detail=some-detail' + + const result = parseCacheStatus(cacheStatus) + + expect(result[0]?.parameters.key).toBe('cache-key-123') + expect(result[0]?.parameters.detail).toBe('some-detail') + }) + + it('handles empty cache status string', () => { + const result = parseCacheStatus('') + + expect(result).toEqual([]) + }) + + it('ignores invalid cache status entries without parameters', () => { + const cacheStatus = '"Netlify Edge", "Valid Cache"; hit' + + const result = parseCacheStatus(cacheStatus) + + expect(result).toHaveLength(1) + expect(result[0]?.cacheName).toBe('Valid Cache') + }) + + it('ignores cache status entries with invalid parameters', () => { + const cacheStatus = '"Netlify Edge"; ; hit' + + const result = parseCacheStatus(cacheStatus) + + expect(result).toHaveLength(1) + expect(result[0]?.cacheName).toBe('Netlify Edge') + expect(result[0]?.parameters.hit).toBe(true) + }) + + it('sorts cache entries according to RFC 9211 precedence', () => { + const cacheStatus = '"Netlify Edge"; hit, "Next.js"; hit, "Netlify Durable"; hit' + + const result = parseCacheStatus(cacheStatus) + + expect(result[0]?.cacheName).toBe('Netlify Edge') + expect(result[1]?.cacheName).toBe('Netlify Durable') + expect(result[2]?.cacheName).toBe('Next.js') + }) - expect(getTimeToLive(age, date, expiresAt, maxAge, now)).toBeUndefined() + it('parses forward parameter values from cache status entries', () => { + const result = parseCacheStatus('"Cache"; fwd=bypass') + expect(result[0]?.parameters.fwd).toBe('bypass') }) }) diff --git a/app/utils/getCacheAnalysis.ts b/app/utils/getCacheAnalysis.ts index 81aba35..ab19e29 100644 --- a/app/utils/getCacheAnalysis.ts +++ b/app/utils/getCacheAnalysis.ts @@ -1,33 +1,5 @@ -import { parse as parseCacheControlHeader } from './cache-control' - -export enum ServedBySource { - CDN = 'CDN', - DurableCache = 'Durable Cache', - Function = 'Function', - EdgeFunction = 'Edge Function', -} - -interface ParsedCacheStatusEntry { - cacheName: string - parameters: { - 'hit': boolean - 'fwd'?: - | 'bypass' - | 'method' - | 'uri-miss' - | 'vary-miss' - | 'miss' - | 'request' - | 'stale' - | 'partial' - 'fwd-status'?: number - 'ttl'?: number - 'stored'?: boolean - 'collapsed'?: boolean - 'key'?: string - 'detail'?: string - } -} +import { getServedBy, type ServedBy } from './getServedBy' +import { parseCacheControl, type ParsedCacheControl } from './parseCacheControl' const CACHE_NAMES_SORTED_BY_RFC_9211 = [ 'Next.js', @@ -87,8 +59,8 @@ export const parseCacheStatus = ( 'fwd': parametersByKey.get( 'fwd', ) as ParsedCacheStatusEntry['parameters']['fwd'], - 'fwd-status': Number(parametersByKey.get('fwd-status')), - 'ttl': Number(parametersByKey.get('ttl')), + 'fwd-status': parametersByKey.has('fwd-status') ? Number(parametersByKey.get('fwd-status')) : undefined, + 'ttl': parametersByKey.has('ttl') ? Number(parametersByKey.get('ttl')) : undefined, 'stored': parametersByKey.has('stored'), 'collapsed': parametersByKey.has('collapsed'), 'key': parametersByKey.get('key'), @@ -106,177 +78,6 @@ export const parseCacheStatus = ( return sortedEntries.toReversed() } -const getServedBySource = ( - cacheHeaders: Headers, - cacheStatus: ParsedCacheStatusEntry[], -): ServedBySource => { - // Per the spec, these are sorted from "the cache closest to the origin server" to "the cache closest to the user". - // 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. - for (const { - cacheName, - parameters: { hit }, - } of cacheStatus) { - if (!hit) continue - - if (cacheName === 'Netlify Edge') return ServedBySource.CDN - if (cacheName === 'Netlify Durable') return ServedBySource.DurableCache - } - - // NOTE: the order is important here, since a response can be served by a Function even - // though one or more Edge Functions are also invoked (as middleware). - if (cacheHeaders.has('Debug-X-NF-Function-Type')) - return ServedBySource.Function - - if (cacheHeaders.has('Debug-X-NF-Edge-Functions')) - return ServedBySource.EdgeFunction - - throw new Error( - `Could not determine who served the request. Cache status: ${cacheStatus}`, - ) -} - -/** - * There is a bug where sometimes duplicate hosts are returned in the `Debug-X-BB-Host-Id` header. This is - * doubly confusing because there are legitimate cases where the same node could be involved more - * than once in the handling of a given request, but we can't distinguish those from dupes. So just dedupe. - */ -const fixDuplicatedCdnNodes = (unfixedCdnNodes: string): string => { - return Array.from(new Set(unfixedCdnNodes.split(', '))).join(', ') -} - -interface ServedBy { - source: ServedBySource - cdnNodes: string -} - -const getServedBy = ( - cacheHeaders: Headers, - cacheStatus: ParsedCacheStatusEntry[], -): ServedBy => { - const source = getServedBySource(cacheHeaders, cacheStatus) - const unfixedCdnNodes - = cacheHeaders.get('Debug-X-BB-Host-Id') ?? 'unknown CDN node' - return { - source, - cdnNodes: fixDuplicatedCdnNodes(unfixedCdnNodes), - } -} - -export const getTimeToLive = ( - age: number | undefined, - date: Date | undefined, - expiresAt: Date | undefined, - maxAge: number | null, - now: number, -): number | undefined => { - // TODO(serhalp) This implementation is madness. There must be a simpler way to do this and/or a - // library we can use. - - const effectiveDate = date ?? new Date(now) - const effectiveAge = age ?? (now - effectiveDate.getTime()) / 1000 - const trulyEffectiveDate = date ?? new Date(now - 1000 * effectiveAge) - - const effectiveMaxAge - = maxAge - ?? (expiresAt != null - ? (expiresAt.getTime() - trulyEffectiveDate.getTime()) / 1000 - : undefined) - - if (effectiveMaxAge != null) { - return effectiveMaxAge - effectiveAge - } -} - -interface ParsedCacheControl { - // TODO(serhalp) Split into `isCacheable`, `isCdnCacheable`, `isNetlifyCdnCacheable` - isCacheable: boolean - age?: number - date?: Date - etag?: string - expiresAt?: Date - ttl?: number - cdnTtl?: number - netlifyCdnTtl?: number - vary?: string - netlifyVary?: string - // TODO(serhalp) Split into `revalidate`, `cdnRevalidate`, `netlifyCdnRevalidate` - revalidate?: 'must-revalidate' | 'immutable' - // TODO(serhalp) `swc`, `cdnSwc`, `netlifyCdnSwc` -} - -const parseCacheControl = ( - cacheHeaders: Headers, - now: number, -): ParsedCacheControl => { - const ageHeader = cacheHeaders.get('Age') - const dateHeader = cacheHeaders.get('Date') - const expiresHeader = cacheHeaders.get('Expires') - const cacheControl = parseCacheControlHeader( - cacheHeaders.get('Cache-Control'), - ) - const cdnCacheControl = parseCacheControlHeader( - cacheHeaders.get('CDN-Cache-Control'), - ) - const netlifyCdnCacheControl = parseCacheControlHeader( - cacheHeaders.get('Netlify-CDN-Cache-Control'), - ) - - const age - = ageHeader != null && ageHeader.length > 0 - ? Number.parseInt(ageHeader) - : undefined - const date = dateHeader ? new Date(dateHeader) : undefined - const expiresAt = expiresHeader ? new Date(expiresHeader) : undefined - - return { - // TODO(serhalp) Actually implement complete logic - isCacheable: - cacheControl.private !== true - && cacheControl.noStore !== true - && cacheControl.noCache !== true, - age, - date, - etag: cacheHeaders.get('ETag') ?? undefined, - expiresAt, - ttl: getTimeToLive(age, date, expiresAt, cacheControl.maxAge, now), - cdnTtl: getTimeToLive( - age, - date, - expiresAt, - // TODO(serhalp) Verify this is the correct order of precedence - cdnCacheControl.sharedMaxAge - ?? cdnCacheControl.maxAge - ?? cacheControl.sharedMaxAge - ?? cacheControl.maxAge, - now, - ), - netlifyCdnTtl: getTimeToLive( - age, - date, - expiresAt, - // TODO(serhalp) Verify this is the correct order of precedence - netlifyCdnCacheControl.sharedMaxAge - ?? netlifyCdnCacheControl.maxAge - ?? cdnCacheControl.sharedMaxAge - ?? cdnCacheControl.maxAge - ?? cacheControl.sharedMaxAge - ?? cacheControl.maxAge, - now, - ), - vary: cacheHeaders.get('Vary') ?? undefined, - netlifyVary: cacheHeaders.get('Netlify-Vary') ?? undefined, - // TODO(serhalp) Support weirder cases? `proxy-revalidate`, must-understand`, etc. - revalidate: - cacheControl.mustRevalidate === true - ? 'must-revalidate' - : cacheControl.immutable === true - ? 'immutable' - : undefined, - } -} - export interface CacheAnalysis { servedBy: ServedBy cacheStatus: ParsedCacheStatusEntry[] diff --git a/app/utils/getServedBy.test.ts b/app/utils/getServedBy.test.ts new file mode 100644 index 0000000..d0c82fe --- /dev/null +++ b/app/utils/getServedBy.test.ts @@ -0,0 +1,186 @@ +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', () => { + const headers = new Headers({ + 'Debug-X-BB-Host-Id': 'node1.example.com', + }) + const cacheStatus: ParsedCacheStatusEntry[] = [ + { + cacheName: 'Netlify Edge', + parameters: { + hit: true, + stored: false, + collapsed: false, + }, + }, + ] + + const result = getServedBy(headers, cacheStatus) + + expect(result.source).toBe(ServedBySource.CDN) + expect(result.cdnNodes).toBe('node1.example.com') + }) + + it('prioritizes CDN hit over durable cache hit when both are present', () => { + const headers = new Headers({ + 'Debug-X-BB-Host-Id': 'node1.example.com', + }) + const cacheStatus: ParsedCacheStatusEntry[] = [ + { + cacheName: 'Netlify Edge', + parameters: { + hit: true, + stored: false, + collapsed: false, + }, + }, + { + cacheName: 'Netlify Durable', + parameters: { + hit: true, + stored: false, + collapsed: false, + }, + }, + ] + + const result = getServedBy(headers, cacheStatus) + + expect(result.source).toBe(ServedBySource.CDN) + }) + + it('returns DurableCache when Netlify Durable cache has a hit', () => { + const headers = new Headers({ + 'Debug-X-BB-Host-Id': 'node1.example.com', + }) + const cacheStatus: ParsedCacheStatusEntry[] = [ + { + cacheName: 'Netlify Durable', + parameters: { + hit: true, + stored: false, + collapsed: false, + }, + }, + ] + + const result = getServedBy(headers, cacheStatus) + + expect(result.source).toBe(ServedBySource.DurableCache) + }) + + it('returns Function when Debug-X-NF-Function-Type header is present', () => { + const headers = new Headers({ + 'Debug-X-NF-Function-Type': 'edge', + 'Debug-X-BB-Host-Id': 'node1.example.com', + }) + const cacheStatus: ParsedCacheStatusEntry[] = [] + + const result = getServedBy(headers, cacheStatus) + + expect(result.source).toBe(ServedBySource.Function) + }) + + it('prioritizes function over edge function when both headers are present', () => { + const headers = new Headers({ + 'Debug-X-NF-Function-Type': 'edge', + 'Debug-X-NF-Edge-Functions': 'middleware', + 'Debug-X-BB-Host-Id': 'node1.example.com', + }) + const cacheStatus: ParsedCacheStatusEntry[] = [] + + const result = getServedBy(headers, cacheStatus) + + expect(result.source).toBe(ServedBySource.Function) + }) + + it('returns EdgeFunction when Debug-X-NF-Edge-Functions header is present', () => { + const headers = new Headers({ + 'Debug-X-NF-Edge-Functions': 'middleware', + 'Debug-X-BB-Host-Id': 'node1.example.com', + }) + const cacheStatus: ParsedCacheStatusEntry[] = [] + + const result = getServedBy(headers, cacheStatus) + + expect(result.source).toBe(ServedBySource.EdgeFunction) + }) + + it('removes duplicate CDN nodes from Debug-X-BB-Host-Id header', () => { + const headers = new Headers({ + 'Debug-X-BB-Host-Id': 'node1.example.com, node1.example.com, node2.example.com', + }) + const cacheStatus: ParsedCacheStatusEntry[] = [ + { + cacheName: 'Netlify Edge', + parameters: { + hit: true, + stored: false, + collapsed: false, + }, + }, + ] + + const result = getServedBy(headers, cacheStatus) + + expect(result.cdnNodes).toBe('node1.example.com, node2.example.com') + }) + + it('uses fallback when Debug-X-BB-Host-Id header is missing', () => { + const headers = new Headers({}) + const cacheStatus: ParsedCacheStatusEntry[] = [ + { + cacheName: 'Netlify Edge', + parameters: { + hit: true, + stored: false, + collapsed: false, + }, + }, + ] + + const result = getServedBy(headers, cacheStatus) + + expect(result.cdnNodes).toBe('unknown CDN node') + }) + + it('throws error when no serving source can be determined', () => { + const headers = new Headers({}) + const cacheStatus: ParsedCacheStatusEntry[] = [] + + expect(() => getServedBy(headers, cacheStatus)).toThrow( + 'Could not determine who served the request', + ) + }) + + it('ignores cache entries without hits', () => { + const headers = new Headers({ + 'Debug-X-BB-Host-Id': 'node1.example.com', + }) + const cacheStatus: ParsedCacheStatusEntry[] = [ + { + cacheName: 'Netlify Edge', + parameters: { + hit: false, + stored: false, + collapsed: false, + }, + }, + { + cacheName: 'Netlify Durable', + parameters: { + hit: true, + stored: false, + collapsed: false, + }, + }, + ] + + const result = getServedBy(headers, cacheStatus) + + expect(result.source).toBe(ServedBySource.DurableCache) + }) +}) diff --git a/app/utils/getServedBy.ts b/app/utils/getServedBy.ts new file mode 100644 index 0000000..d3e212b --- /dev/null +++ b/app/utils/getServedBy.ts @@ -0,0 +1,86 @@ +export enum ServedBySource { + CDN = 'CDN', + DurableCache = 'Durable Cache', + Function = 'Function', + EdgeFunction = 'Edge Function', +} + +export interface ParsedCacheStatusEntry { + cacheName: string + parameters: { + 'hit': boolean + 'fwd'?: + | 'bypass' + | 'method' + | 'uri-miss' + | 'vary-miss' + | 'miss' + | 'request' + | 'stale' + | 'partial' + 'fwd-status'?: number + 'ttl'?: number + 'stored'?: boolean + 'collapsed'?: boolean + 'key'?: string + 'detail'?: string + } +} + +export interface ServedBy { + source: ServedBySource + cdnNodes: string +} + +const getServedBySource = ( + cacheHeaders: Headers, + cacheStatus: ParsedCacheStatusEntry[], +): ServedBySource => { + // Per the spec, these are sorted from "the cache closest to the origin server" to "the cache closest to the user". + // 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. + for (const { + cacheName, + parameters: { hit }, + } of cacheStatus) { + if (!hit) continue + + if (cacheName === 'Netlify Edge') return ServedBySource.CDN + if (cacheName === 'Netlify Durable') return ServedBySource.DurableCache + } + + // NOTE: the order is important here, since a response can be served by a Function even + // though one or more Edge Functions are also invoked (as middleware). + if (cacheHeaders.has('Debug-X-NF-Function-Type')) + return ServedBySource.Function + + if (cacheHeaders.has('Debug-X-NF-Edge-Functions')) + return ServedBySource.EdgeFunction + + throw new Error( + `Could not determine who served the request. Cache status: ${cacheStatus}`, + ) +} + +/** + * There is a bug where sometimes duplicate hosts are returned in the `Debug-X-BB-Host-Id` header. This is + * doubly confusing because there are legitimate cases where the same node could be involved more + * than once in the handling of a given request, but we can't distinguish those from dupes. So just dedupe. + */ +const fixDuplicatedCdnNodes = (unfixedCdnNodes: string): string => { + return Array.from(new Set(unfixedCdnNodes.split(', '))).join(', ') +} + +export const getServedBy = ( + cacheHeaders: Headers, + cacheStatus: ParsedCacheStatusEntry[], +): ServedBy => { + const source = getServedBySource(cacheHeaders, cacheStatus) + const unfixedCdnNodes + = cacheHeaders.get('Debug-X-BB-Host-Id') ?? 'unknown CDN node' + return { + source, + cdnNodes: fixDuplicatedCdnNodes(unfixedCdnNodes), + } +} diff --git a/app/utils/getTimeToLive.test.ts b/app/utils/getTimeToLive.test.ts new file mode 100644 index 0000000..910d176 --- /dev/null +++ b/app/utils/getTimeToLive.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from 'vitest' + +import { getTimeToLive } from './getTimeToLive' + +describe('getTimeToLive', () => { + it('returns the diff in seconds from `maxAge` to `age` if they are both defined', () => { + const age = 10 + const date = undefined + const expiresAt = undefined + const maxAge = 25 + const now = Date.now() + + expect(getTimeToLive(age, date, expiresAt, maxAge, now)).toBe(15) + }) + + it('returns the diff in seconds from `maxAge` to `age` if they are both defined as well as `date`', () => { + const age = 10 + const date = new Date(999_999_999) + const expiresAt = undefined + const maxAge = 25 + const now = Date.now() + + expect(getTimeToLive(age, date, expiresAt, maxAge, now)).toBe(15) + }) + + it('returns the diff in seconds from `maxAge` to `now - date` if `maxAge` and `date` are defined but not `age`', () => { + const age = undefined + const date = new Date(1_000_000) + const expiresAt = undefined + const maxAge = 25 + const now = new Date(1_000_000 + 10_000).getTime() + + expect(getTimeToLive(age, date, expiresAt, maxAge, now)).toBe(15) + }) + + it('returns the diff in seconds from `maxAge` to `now` if `maxAge` is defined but neither `age` nor `date`', () => { + const age = undefined + const date = undefined + const expiresAt = undefined + const maxAge = 25 + const now = new Date(1_000_000 + 10_000).getTime() + + expect(getTimeToLive(age, date, expiresAt, maxAge, now)).toBe(25) + }) + + it('returns the diff in seconds from `expiresAt` to `now - age` if `maxAge` and `date` are not defined and `expiresAt` and `age` are defined', () => { + const age = 10 + const date = undefined + const expiresAt = new Date(1_000_000 + 25_000) + const maxAge = null + const now = new Date(1_000_000 + 10_000).getTime() + + expect(getTimeToLive(age, date, expiresAt, maxAge, now)).toBe(15) + }) + + // FIXME(serhalp) Real bug. Fix logic. It's depending on `now` when given two absolute dates... + it.fails( + 'returns the diff in seconds from `expiresAt` to `date` if `maxAge` and `age` are not defined and `expiresAt` and `date` are defined', + () => { + const age = undefined + const date = new Date(1_000_000) + const expiresAt = new Date(1_000_000 + 15_000) + const maxAge = null + const now = Date.now() + + expect(getTimeToLive(age, date, expiresAt, maxAge, now)).toBe(15) + }, + ) + + it('returns the diff in seconds from `expiresAt` to `now` if `maxAge`, `age`, and `date` are not defined and `expiresAt` is defined', () => { + const age = undefined + const date = undefined + const expiresAt = new Date(1_000_000 + 15_000) + const maxAge = null + const now = new Date(1_000_000).getTime() + + expect(getTimeToLive(age, date, expiresAt, maxAge, now)).toBe(15) + }) + + it('returns `undefined` if `maxAge` and `expiresAt` are not defined', () => { + const age = 10 + const date = new Date() + const expiresAt = undefined + const maxAge = null + const now = Date.now() + + expect(getTimeToLive(age, date, expiresAt, maxAge, now)).toBeUndefined() + }) +}) diff --git a/app/utils/getTimeToLive.ts b/app/utils/getTimeToLive.ts new file mode 100644 index 0000000..769e881 --- /dev/null +++ b/app/utils/getTimeToLive.ts @@ -0,0 +1,24 @@ +export const getTimeToLive = ( + age: number | undefined, + date: Date | undefined, + expiresAt: Date | undefined, + maxAge: number | null, + now: number, +): number | undefined => { + // TODO(serhalp) This implementation is madness. There must be a simpler way to do this and/or a + // library we can use. + + const effectiveDate = date ?? new Date(now) + const effectiveAge = age ?? (now - effectiveDate.getTime()) / 1000 + const trulyEffectiveDate = date ?? new Date(now - 1000 * effectiveAge) + + const effectiveMaxAge + = maxAge + ?? (expiresAt != null + ? (expiresAt.getTime() - trulyEffectiveDate.getTime()) / 1000 + : undefined) + + if (effectiveMaxAge != null) { + return effectiveMaxAge - effectiveAge + } +} diff --git a/app/utils/parseCacheControl.test.ts b/app/utils/parseCacheControl.test.ts new file mode 100644 index 0000000..fc39fe8 --- /dev/null +++ b/app/utils/parseCacheControl.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect } from 'vitest' + +import { parseCacheControl } from './parseCacheControl' + +describe('parseCacheControl', () => { + it('determines cache as cacheable by default', () => { + const headers = new Headers({ + 'Cache-Control': 'public, max-age=3600', + }) + const now = Date.now() + + const result = parseCacheControl(headers, now) + + expect(result.isCacheable).toBe(true) + }) + + it('determines cache as non-cacheable when private directive is present', () => { + const headers = new Headers({ + 'Cache-Control': 'private, max-age=3600', + }) + const now = Date.now() + + const result = parseCacheControl(headers, now) + + expect(result.isCacheable).toBe(false) + }) + + it('determines cache as non-cacheable when no-store directive is present', () => { + const headers = new Headers({ + 'Cache-Control': 'no-store', + }) + const now = Date.now() + + const result = parseCacheControl(headers, now) + + expect(result.isCacheable).toBe(false) + }) + + it('determines cache as non-cacheable when no-cache directive is present', () => { + const headers = new Headers({ + 'Cache-Control': 'no-cache', + }) + const now = Date.now() + + const result = parseCacheControl(headers, now) + + expect(result.isCacheable).toBe(false) + }) + + it('calculates CDN TTL with s-maxage precedence over max-age', () => { + const headers = new Headers({ + 'Cache-Control': 'max-age=1000', + 'CDN-Cache-Control': 's-maxage=2000, max-age=1500', + 'Age': '100', + }) + const now = Date.now() + + const result = parseCacheControl(headers, now) + + expect(typeof result.cdnTtl).toBe('number') + expect(result.cdnTtl).toBe(1900) // 2000 - 100 + }) + + it('falls back to CDN-Cache-Control max-age when s-maxage not present', () => { + const headers = new Headers({ + 'Cache-Control': 'max-age=1000', + 'CDN-Cache-Control': 'max-age=1500', + 'Age': '100', + }) + const now = Date.now() + + const result = parseCacheControl(headers, now) + + expect(result.cdnTtl).toBe(1400) // 1500 - 100 + }) + + it('calculates Netlify CDN TTL with max-age when present', () => { + const headers = new Headers({ + 'Cache-Control': 'max-age=1000', + 'CDN-Cache-Control': 's-maxage=2000', + 'Netlify-CDN-Cache-Control': 'max-age=3000', + 'Age': '100', + }) + const now = Date.now() + + const result = parseCacheControl(headers, now) + + expect(result.netlifyCdnTtl).toBe(2900) // 3000 - 100 + }) + + it('returns must-revalidate when must-revalidate directive is present', () => { + const headers = new Headers({ + 'Cache-Control': 'max-age=3600, must-revalidate', + }) + const now = Date.now() + + const result = parseCacheControl(headers, now) + + expect(result.revalidate).toBe('must-revalidate') + }) + + it('returns immutable when immutable directive is present', () => { + const headers = new Headers({ + 'Cache-Control': 'max-age=3600, immutable', + }) + const now = Date.now() + + const result = parseCacheControl(headers, now) + + expect(result.revalidate).toBe('immutable') + }) + + it('prioritizes must-revalidate over immutable when both are present', () => { + const headers = new Headers({ + 'Cache-Control': 'max-age=3600, must-revalidate, immutable', + }) + const now = Date.now() + + const result = parseCacheControl(headers, now) + + expect(result.revalidate).toBe('must-revalidate') + }) + + it('returns undefined when neither must-revalidate nor immutable is present', () => { + const headers = new Headers({ + 'Cache-Control': 'max-age=3600', + }) + const now = Date.now() + + const result = parseCacheControl(headers, now) + + expect(result.revalidate).toBeUndefined() + }) + + it('parses Age header when value is present', () => { + const headers = new Headers({ + Age: '1800', + }) + const now = Date.now() + + const result = parseCacheControl(headers, now) + + expect(result.age).toBe(1800) + }) + + it('ignores empty Age header', () => { + const headers = new Headers({ + Age: '', + }) + const now = Date.now() + + const result = parseCacheControl(headers, now) + + expect(result.age).toBeUndefined() + }) + + it('parses Date header when present', () => { + const headers = new Headers({ + Date: 'Wed, 21 Oct 2015 07:28:00 GMT', + }) + const now = Date.now() + + const result = parseCacheControl(headers, now) + + expect(result.date).toEqual(new Date('Wed, 21 Oct 2015 07:28:00 GMT')) + }) + + it('parses Expires header when present', () => { + const headers = new Headers({ + Expires: 'Wed, 21 Oct 2015 08:28:00 GMT', + }) + const now = Date.now() + + const result = parseCacheControl(headers, now) + + expect(result.expiresAt).toEqual(new Date('Wed, 21 Oct 2015 08:28:00 GMT')) + }) + + it('extracts ETag header when present', () => { + const headers = new Headers({ + ETag: '"abc123-def456"', + }) + const now = Date.now() + + const result = parseCacheControl(headers, now) + + expect(result.etag).toBe('"abc123-def456"') + }) + + it('extracts Vary header when present', () => { + const headers = new Headers({ + Vary: 'Accept-Encoding, User-Agent', + }) + const now = Date.now() + + const result = parseCacheControl(headers, now) + + expect(result.vary).toBe('Accept-Encoding, User-Agent') + }) + + it('extracts Netlify-Vary header when present', () => { + const headers = new Headers({ + 'Netlify-Vary': 'Cookie, Authorization', + }) + const now = Date.now() + + const result = parseCacheControl(headers, now) + + expect(result.netlifyVary).toBe('Cookie, Authorization') + }) +}) diff --git a/app/utils/parseCacheControl.ts b/app/utils/parseCacheControl.ts new file mode 100644 index 0000000..a0b0af0 --- /dev/null +++ b/app/utils/parseCacheControl.ts @@ -0,0 +1,90 @@ +import { parse as parseCacheControlHeader } from './cache-control' +import { getTimeToLive } from './getTimeToLive' + +export interface ParsedCacheControl { + // TODO(serhalp) Split into `isCacheable`, `isCdnCacheable`, `isNetlifyCdnCacheable` + isCacheable: boolean + age?: number + date?: Date + etag?: string + expiresAt?: Date + ttl?: number + cdnTtl?: number + netlifyCdnTtl?: number + vary?: string + netlifyVary?: string + // TODO(serhalp) Split into `revalidate`, `cdnRevalidate`, `netlifyCdnRevalidate` + revalidate?: 'must-revalidate' | 'immutable' + // TODO(serhalp) `swc`, `cdnSwc`, `netlifyCdnSwc` +} + +export const parseCacheControl = ( + cacheHeaders: Headers, + now: number, +): ParsedCacheControl => { + const ageHeader = cacheHeaders.get('Age') + const dateHeader = cacheHeaders.get('Date') + const expiresHeader = cacheHeaders.get('Expires') + const cacheControl = parseCacheControlHeader( + cacheHeaders.get('Cache-Control'), + ) + const cdnCacheControl = parseCacheControlHeader( + cacheHeaders.get('CDN-Cache-Control'), + ) + const netlifyCdnCacheControl = parseCacheControlHeader( + cacheHeaders.get('Netlify-CDN-Cache-Control'), + ) + + const age + = ageHeader != null && ageHeader.length > 0 + ? Number.parseInt(ageHeader) + : undefined + const date = dateHeader ? new Date(dateHeader) : undefined + const expiresAt = expiresHeader ? new Date(expiresHeader) : undefined + + return { + // TODO(serhalp) Actually implement complete logic + isCacheable: + cacheControl.private !== true + && cacheControl.noStore !== true + && cacheControl.noCache !== true, + age, + date, + etag: cacheHeaders.get('ETag') ?? undefined, + expiresAt, + ttl: getTimeToLive(age, date, expiresAt, cacheControl.maxAge, now), + cdnTtl: getTimeToLive( + age, + date, + expiresAt, + // TODO(serhalp) Verify this is the correct order of precedence + cdnCacheControl.sharedMaxAge + ?? cdnCacheControl.maxAge + ?? cacheControl.sharedMaxAge + ?? cacheControl.maxAge, + now, + ), + netlifyCdnTtl: getTimeToLive( + age, + date, + expiresAt, + // TODO(serhalp) Verify this is the correct order of precedence + netlifyCdnCacheControl.sharedMaxAge + ?? netlifyCdnCacheControl.maxAge + ?? cdnCacheControl.sharedMaxAge + ?? cdnCacheControl.maxAge + ?? cacheControl.sharedMaxAge + ?? cacheControl.maxAge, + now, + ), + vary: cacheHeaders.get('Vary') ?? undefined, + netlifyVary: cacheHeaders.get('Netlify-Vary') ?? undefined, + // TODO(serhalp) Support weirder cases? `proxy-revalidate`, must-understand`, etc. + revalidate: + cacheControl.mustRevalidate === true + ? 'must-revalidate' + : cacheControl.immutable === true + ? 'immutable' + : undefined, + } +}