Skip to content

Commit 5eb7a8b

Browse files
committed
fix: optimize cookie value comparison using FNV-1a hash
- Add FNV-1a hash function for fast object comparison - Use hash-based comparison to avoid unnecessary cookie updates - Cache JSON.stringify result to avoid duplicate serialization - Improve code comments explaining hash comparison logic - Add comprehensive tests for repeated identical values and large objects - Rename hashObject to hashString for clarity
1 parent a6ea431 commit 5eb7a8b

File tree

2 files changed

+117
-5
lines changed

2 files changed

+117
-5
lines changed

src/cookies.ts

Lines changed: 38 additions & 5 deletions
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>,
@@ -162,18 +180,33 @@ export class Cookie<T> implements ElysiaCookie {
162180
// Simple equality check
163181
if (current === value) return
164182

165-
// For objects, do a deep equality check
183+
// For objects, use hash-based comparison for performance
184+
// Note: Uses JSON.stringify for comparison, so key order matters
185+
// { a: 1, b: 2 } and { b: 2, a: 1 } are treated as different values
166186
if (
167187
typeof current === 'object' &&
168188
current !== null &&
169189
typeof value === 'object' &&
170190
value !== null
171191
) {
172192
try {
173-
if (JSON.stringify(current) === JSON.stringify(value)) return
174-
} catch {
175-
// If stringify fails, proceed with setting the value
176-
}
193+
// Cache stringified value to avoid duplicate stringify calls
194+
const valueStr = JSON.stringify(value)
195+
const newHash = hashString(valueStr)
196+
197+
// If hash differs from cached hash, value definitely changed
198+
if (this.valueHash !== undefined && this.valueHash !== newHash) {
199+
this.valueHash = newHash
200+
}
201+
// First set (valueHash undefined) OR hashes match: do deep comparison
202+
else {
203+
if (JSON.stringify(current) === valueStr) {
204+
this.valueHash = newHash
205+
return // Values are identical, skip update
206+
}
207+
this.valueHash = newHash
208+
}
209+
} catch {}
177210
}
178211

179212
// Only create entry in jar if value actually changed

test/cookie/unchanged.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,4 +106,83 @@ 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+
})
109188
})

0 commit comments

Comments
 (0)