Skip to content

Commit 66a16a6

Browse files
authored
Merge pull request #1568 from YasserGomma/fix/cookie-hash-optimization
fix: Optimize cookie value comparison using FNV-1a hash
2 parents 61fc1a1 + 50e49c1 commit 66a16a6

File tree

2 files changed

+183
-1
lines changed

2 files changed

+183
-1
lines changed

src/cookies.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,22 @@ import { InvalidCookieSignature } from './error'
88
import type { Context } from './context'
99
import type { Prettify } from './types'
1010

11+
// FNV-1a hash for fast string hashing
12+
const hashString = (str: string): number => {
13+
const FNV_OFFSET_BASIS = 2166136261
14+
const FNV_PRIME = 16777619
15+
16+
let hash = FNV_OFFSET_BASIS
17+
const len = str.length
18+
19+
for (let i = 0; i < len; i++) {
20+
hash ^= str.charCodeAt(i)
21+
hash = Math.imul(hash, FNV_PRIME)
22+
}
23+
24+
return hash >>> 0
25+
}
26+
1127
export interface CookieOptions {
1228
/**
1329
* Specifies the value for the {@link https://tools.ietf.org/html/rfc6265#section-5.2.3|Domain Set-Cookie attribute}. By default, no
@@ -125,6 +141,8 @@ export type ElysiaCookie = Prettify<
125141
type Updater<T> = T | ((value: T) => T)
126142

127143
export class Cookie<T> implements ElysiaCookie {
144+
private valueHash?: number
145+
128146
constructor(
129147
private name: string,
130148
private jar: Record<string, ElysiaCookie>,
@@ -139,6 +157,8 @@ export class Cookie<T> implements ElysiaCookie {
139157
if (!(this.name in this.jar)) this.jar[this.name] = this.initial
140158

141159
this.jar[this.name] = jar
160+
// Invalidate hash cache when jar is modified directly
161+
this.valueHash = undefined
142162
}
143163

144164
protected get setCookie() {
@@ -156,7 +176,44 @@ export class Cookie<T> implements ElysiaCookie {
156176
}
157177

158178
set value(value: T) {
159-
this.setCookie.value = value;
179+
// Check if value actually changed before creating entry in jar
180+
const current = this.cookie.value
181+
182+
// Simple equality check
183+
if (current === value) return
184+
185+
// For objects, use hash-based comparison for performance
186+
// Note: Uses JSON.stringify for comparison, so key order matters
187+
// { a: 1, b: 2 } and { b: 2, a: 1 } are treated as different values
188+
if (
189+
typeof current === 'object' &&
190+
current !== null &&
191+
typeof value === 'object' &&
192+
value !== null
193+
) {
194+
try {
195+
// Cache stringified value to avoid duplicate stringify calls
196+
const valueStr = JSON.stringify(value)
197+
const newHash = hashString(valueStr)
198+
199+
// If hash differs from cached hash, value definitely changed
200+
if (this.valueHash !== undefined && this.valueHash !== newHash) {
201+
this.valueHash = newHash
202+
}
203+
// First set (valueHash undefined) OR hashes match: do deep comparison
204+
else {
205+
if (JSON.stringify(current) === valueStr) {
206+
this.valueHash = newHash
207+
return // Values are identical, skip update
208+
}
209+
this.valueHash = newHash
210+
}
211+
} catch {}
212+
}
213+
214+
// Only create entry in jar if value actually changed
215+
if (!(this.name in this.jar)) this.jar[this.name] = { ...this.initial }
216+
this.jar[this.name].value = value
160217
}
161218

162219
get expires() {

test/cookie/unchanged.test.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,4 +106,129 @@ describe('Cookie - Unchanged Values', () => {
106106

107107
expect(response.headers.getAll('set-cookie').length).toBeGreaterThan(0)
108108
})
109+
110+
it('should not send set-cookie header when setting same object value as incoming cookie', async () => {
111+
const app = new Elysia().post('/update', ({ cookie: { data } }) => {
112+
// Set to same value as incoming cookie
113+
data.value = { id: 123, name: 'test' }
114+
return 'ok'
115+
})
116+
117+
// First request: set the cookie
118+
const firstRes = await app.handle(
119+
new Request('http://localhost/update', { method: 'POST' })
120+
)
121+
const setCookie = firstRes.headers.get('set-cookie')
122+
expect(setCookie).toBeTruthy()
123+
124+
// Second request: send cookie back and set to same value
125+
const secondRes = await app.handle(
126+
new Request('http://localhost/update', {
127+
method: 'POST',
128+
headers: {
129+
cookie: setCookie!.split(';')[0]
130+
}
131+
})
132+
)
133+
134+
// Should not send Set-Cookie since value didn't change
135+
expect(secondRes.headers.getAll('set-cookie').length).toBe(0)
136+
})
137+
138+
it('should not send set-cookie header for large unchanged object values', async () => {
139+
const large = {
140+
users: Array.from({ length: 100 }, (_, i) => ({
141+
id: i,
142+
name: `User ${i}`
143+
}))
144+
}
145+
146+
const app = new Elysia().post('/update', ({ cookie: { data } }) => {
147+
data.value = large
148+
return 'ok'
149+
})
150+
151+
// First request: set the cookie
152+
const firstRes = await app.handle(
153+
new Request('http://localhost/update', { method: 'POST' })
154+
)
155+
const setCookie = firstRes.headers.get('set-cookie')
156+
expect(setCookie).toBeTruthy()
157+
158+
// Second request: send cookie back and set to same value
159+
const secondRes = await app.handle(
160+
new Request('http://localhost/update', {
161+
method: 'POST',
162+
headers: {
163+
cookie: setCookie!.split(';')[0]
164+
}
165+
})
166+
)
167+
168+
// Should not send Set-Cookie since value didn't change
169+
expect(secondRes.headers.getAll('set-cookie').length).toBe(0)
170+
})
171+
172+
it('should optimize multiple assignments of same object in single request', async () => {
173+
const app = new Elysia().post('/multi', ({ cookie: { data } }) => {
174+
// Multiple assignments of the same value
175+
data.value = { id: 123, name: 'test' }
176+
data.value = { id: 123, name: 'test' }
177+
data.value = { id: 123, name: 'test' }
178+
return 'ok'
179+
})
180+
181+
const res = await app.handle(
182+
new Request('http://localhost/multi', { method: 'POST' })
183+
)
184+
185+
// Should only produce one Set-Cookie header
186+
expect(res.headers.getAll('set-cookie').length).toBe(1)
187+
})
188+
189+
it('should invalidate hash cache when using update() method', async () => {
190+
const app = new Elysia().post('/cache-invalidation', ({ cookie: { data } }) => {
191+
// Set initial value
192+
data.value = { id: 1, name: 'first' }
193+
194+
// Modify via update() - should invalidate cache
195+
data.update({ value: { id: 2, name: 'second' } })
196+
197+
// Set to the updated value again - should detect as unchanged
198+
data.value = { id: 2, name: 'second' }
199+
200+
return 'ok'
201+
})
202+
203+
const res = await app.handle(
204+
new Request('http://localhost/cache-invalidation', { method: 'POST' })
205+
)
206+
207+
// Should only have one Set-Cookie header (for final value)
208+
const setCookieHeaders = res.headers.getAll('set-cookie')
209+
expect(setCookieHeaders.length).toBe(1)
210+
expect(setCookieHeaders[0]).toContain('id')
211+
})
212+
213+
it('should invalidate hash cache when using set() method', async () => {
214+
const app = new Elysia().post('/cache-set', ({ cookie: { data } }) => {
215+
// Set initial value
216+
data.value = { id: 1 }
217+
218+
// Modify via set() - should invalidate cache
219+
data.set({ value: { id: 2 } })
220+
221+
// Set to the updated value again - should detect as unchanged
222+
data.value = { id: 2 }
223+
224+
return 'ok'
225+
})
226+
227+
const res = await app.handle(
228+
new Request('http://localhost/cache-set', { method: 'POST' })
229+
)
230+
231+
// Should only have one Set-Cookie header
232+
expect(res.headers.getAll('set-cookie').length).toBe(1)
233+
})
109234
})

0 commit comments

Comments
 (0)