Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 50 additions & 2 deletions packages/next/src/server/lib/etag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,57 @@ export const fnv1a52 = (str: string) => {
)
}

/**
* LRU cache for computed ETags. Pre-rendered/static pages produce identical
* payloads across requests, so caching the ETag avoids re-running the O(n)
* fnv1a52 hash on every request.
*
* The cache is bounded by entry count (MAX_ETAG_CACHE_ENTRIES) and skips
* payloads larger than MAX_CACHED_PAYLOAD_LENGTH to avoid holding references
* to very large strings.
*
* V8's native Map string-key hashing is used for lookups, which is
* significantly faster than the JS-level character-by-character FNV-1a loop.
*/
const MAX_ETAG_CACHE_ENTRIES = 512
const MAX_CACHED_PAYLOAD_LENGTH = 512 * 1024 // 512 KB

// Using a Map as an LRU: Map iteration order is insertion order.
// On cache hit we delete + re-insert to move the entry to the end.
// On eviction we delete the first (oldest) entry.
const etagCache = new Map<string, string>()

export const generateETag = (payload: string, weak = false) => {
// Build a cache key that incorporates the `weak` flag so that
// strong and weak ETags for the same payload are cached separately.
// The vast majority of calls use strong (weak=false), so we avoid
// string concatenation in the common case.
const cacheKey = weak ? 'w\0' + payload : payload
Comment on lines +69 to +71
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// The vast majority of calls use strong (weak=false), so we avoid
// string concatenation in the common case.
const cacheKey = weak ? 'w\0' + payload : payload
// Both cases use a distinct prefix to prevent collisions when a
// payload naturally starts with the weak prefix.
const cacheKey = (weak ? 'w\0' : 's\0') + payload

Cache key collision between strong and weak ETags when a strong ETag payload starts with "w\0", causing incorrect cached ETag values to be returned.

Fix on Vercel


const cached = etagCache.get(cacheKey)
if (cached !== undefined) {
// Move to end (most-recently-used) by re-inserting
etagCache.delete(cacheKey)
etagCache.set(cacheKey, cached)
return cached
}

const prefix = weak ? 'W/"' : '"'
return (
const etag =
prefix + fnv1a52(payload).toString(36) + payload.length.toString(36) + '"'
)

// Only cache payloads within the size threshold to avoid pinning
// very large strings in memory.
if (payload.length <= MAX_CACHED_PAYLOAD_LENGTH) {
if (etagCache.size >= MAX_ETAG_CACHE_ENTRIES) {
// Evict the least-recently-used entry (first key in insertion order)
const firstKey = etagCache.keys().next().value
if (firstKey !== undefined) {
etagCache.delete(firstKey)
}
}
etagCache.set(cacheKey, etag)
}

return etag
}
85 changes: 85 additions & 0 deletions test/unit/etag.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/* eslint-env jest */
import { fnv1a52, generateETag } from 'next/dist/server/lib/etag'

describe('fnv1a52', () => {
test('returns a number', () => {
expect(typeof fnv1a52('hello')).toBe('number')
})

test('produces consistent hashes', () => {
const hash1 = fnv1a52('test payload')
const hash2 = fnv1a52('test payload')
expect(hash1).toBe(hash2)
})

test('produces different hashes for different inputs', () => {
const hash1 = fnv1a52('hello')
const hash2 = fnv1a52('world')
expect(hash1).not.toBe(hash2)
})
})

describe('generateETag', () => {
test('generates a strong ETag by default', () => {
const etag = generateETag('test')
expect(etag).toMatch(/^"[a-z0-9]+"$/)
expect(etag).not.toMatch(/^W\//)
})

test('generates a weak ETag when requested', () => {
const etag = generateETag('test', true)
expect(etag).toMatch(/^W\/"[a-z0-9]+"$/)
})

test('produces consistent ETags for the same payload', () => {
const etag1 = generateETag('hello world')
const etag2 = generateETag('hello world')
expect(etag1).toBe(etag2)
})

test('produces different ETags for different payloads', () => {
const etag1 = generateETag('hello')
const etag2 = generateETag('world')
expect(etag1).not.toBe(etag2)
})

test('strong and weak ETags for the same payload differ', () => {
const strong = generateETag('test payload')
const weak = generateETag('test payload', true)
expect(strong).not.toBe(weak)
expect(weak.startsWith('W/"')).toBe(true)
expect(strong.startsWith('"')).toBe(true)
})

test('returns cached result on repeated calls (same reference)', () => {
const payload = 'cached-payload-test-' + Date.now()
// First call computes
const etag1 = generateETag(payload)
// Second call should hit cache and return identical result
const etag2 = generateETag(payload)
expect(etag1).toBe(etag2)
})

test('handles empty string', () => {
const etag = generateETag('')
expect(etag).toMatch(/^"[a-z0-9]+"$/)
})

test('handles large payloads', () => {
const large = 'x'.repeat(100_000)
const etag = generateETag(large)
expect(etag).toMatch(/^"[a-z0-9]+"$/)
// Repeated call for the same large payload should return identical result
expect(generateETag(large)).toBe(etag)
})

test('does not cache payloads exceeding the size threshold', () => {
// Payloads over 512KB are not cached, but should still produce valid ETags
const huge = 'y'.repeat(600_000)
const etag1 = generateETag(huge)
const etag2 = generateETag(huge)
expect(etag1).toMatch(/^"[a-z0-9]+"$/)
// Both should produce the same ETag (just not cached)
expect(etag1).toBe(etag2)
})
})
Loading