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
77 changes: 6 additions & 71 deletions src/cookies.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,11 @@
import { parse, serialize } from 'cookie'

import decode from 'fast-decode-uri-component'

import { isNotEmpty, unsignCookie } from './utils'
import { InvalidCookieSignature } from './error'

import type { Context } from './context'
import type { Prettify } from './types'

// FNV-1a hash for fast string hashing
const hashString = (str: string): number => {
const FNV_OFFSET_BASIS = 2166136261
const FNV_PRIME = 16777619

let hash = FNV_OFFSET_BASIS
const len = str.length

for (let i = 0; i < len; i++) {
hash ^= str.charCodeAt(i)
hash = Math.imul(hash, FNV_PRIME)
}

return hash >>> 0
}

export interface CookieOptions {
/**
* Specifies the value for the {@link https://tools.ietf.org/html/rfc6265#section-5.2.3|Domain Set-Cookie attribute}. By default, no
Expand Down Expand Up @@ -141,10 +123,9 @@ export type ElysiaCookie = Prettify<
type Updater<T> = T | ((value: T) => T)

export class Cookie<T> implements ElysiaCookie {
private valueHash?: number

constructor(
private name: string,
// Modifications here lead to changes in the response headers. Empty at the start of a request.
private jar: Record<string, ElysiaCookie>,
private initial: Partial<ElysiaCookie> = {}
) {}
Expand All @@ -157,8 +138,6 @@ export class Cookie<T> implements ElysiaCookie {
if (!(this.name in this.jar)) this.jar[this.name] = this.initial

this.jar[this.name] = jar
// Invalidate hash cache when jar is modified directly
this.valueHash = undefined
}

protected get setCookie() {
Expand All @@ -176,42 +155,6 @@ export class Cookie<T> implements ElysiaCookie {
}

set value(value: T) {
// Check if value actually changed before creating entry in jar
const current = this.cookie.value

// Simple equality check
if (current === value) return

// For objects, use hash-based comparison for performance
// Note: Uses JSON.stringify for comparison, so key order matters
// { a: 1, b: 2 } and { b: 2, a: 1 } are treated as different values
if (
typeof current === 'object' &&
current !== null &&
typeof value === 'object' &&
value !== null
) {
try {
// Cache stringified value to avoid duplicate stringify calls
const valueStr = JSON.stringify(value)
const newHash = hashString(valueStr)

// If hash differs from cached hash, value definitely changed
if (this.valueHash !== undefined && this.valueHash !== newHash) {
this.valueHash = newHash
}
// First set (valueHash undefined) OR hashes match: do deep comparison
else {
if (JSON.stringify(current) === valueStr) {
this.valueHash = newHash
return // Values are identical, skip update
}
this.valueHash = newHash
}
} catch {}
}

// Only create entry in jar if value actually changed
if (!(this.name in this.jar)) this.jar[this.name] = { ...this.initial }
this.jar[this.name].value = value
}
Expand Down Expand Up @@ -347,18 +290,10 @@ export const createCookieJar = (

return new Proxy(store, {
get(_, key: string) {
if (key in store)
return new Cookie(
key,
set.cookie as Record<string, ElysiaCookie>,
Object.assign({}, initial ?? {}, store[key])
)

return new Cookie(
key,
set.cookie as Record<string, ElysiaCookie>,
Object.assign({}, initial)
)
return new Cookie(key, set.cookie as Record<string, ElysiaCookie>, {
...(initial ?? {}),
...(store[key] ?? {})
})
}
}) as Record<string, Cookie<unknown>>
}
Expand All @@ -385,7 +320,7 @@ export const parseCookie = async (
for (const [name, v] of Object.entries(cookies)) {
if (v === undefined) continue

let value = decode(v)
let value = v

if (value) {
const starts = value.charCodeAt(0)
Expand Down
39 changes: 2 additions & 37 deletions test/cookie/unchanged.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ describe('Cookie - Unchanged Values', () => {
expect(response.headers.getAll('set-cookie').length).toBeGreaterThan(0)
})

it('should not send set-cookie header when setting same object value as incoming cookie', async () => {
it('should idempotently create headers on cookie modifications', async () => {
const app = new Elysia().post('/update', ({ cookie: { data } }) => {
// Set to same value as incoming cookie
data.value = { id: 123, name: 'test' }
Expand All @@ -132,42 +132,7 @@ describe('Cookie - Unchanged Values', () => {
})
)

// Should not send Set-Cookie since value didn't change
expect(secondRes.headers.getAll('set-cookie').length).toBe(0)
})

it('should not send set-cookie header for large unchanged object values', async () => {
const large = {
users: Array.from({ length: 100 }, (_, i) => ({
id: i,
name: `User ${i}`
}))
}

const app = new Elysia().post('/update', ({ cookie: { data } }) => {
data.value = large
return 'ok'
})

// First request: set the cookie
const firstRes = await app.handle(
new Request('http://localhost/update', { method: 'POST' })
)
const setCookie = firstRes.headers.get('set-cookie')
expect(setCookie).toBeTruthy()

// Second request: send cookie back and set to same value
const secondRes = await app.handle(
new Request('http://localhost/update', {
method: 'POST',
headers: {
cookie: setCookie!.split(';')[0]
}
})
)

// Should not send Set-Cookie since value didn't change
expect(secondRes.headers.getAll('set-cookie').length).toBe(0)
expect(secondRes.headers.get('set-cookie')).toBeTruthy()
})

it('should optimize multiple assignments of same object in single request', async () => {
Expand Down
Loading