Skip to content
Draft
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
11 changes: 11 additions & 0 deletions packages/core/src/browser/browser.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,17 @@ export interface CookieStore extends EventTarget {
partitioned?: boolean
}>
>
set(options: {
name: string
value: string
domain?: string
path?: string
expires?: number | Date
sameSite?: 'strict' | 'lax' | 'none'
secure?: boolean
partitioned?: boolean
}): Promise<void>
delete(options: { name: string; domain?: string; path?: string }): Promise<void>
}

export interface CookieStoreEventMap {
Expand Down
38 changes: 38 additions & 0 deletions packages/core/src/browser/cookieAccess.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { registerCleanupTask } from '../../test'
import { deleteCookie, getCookie } from './cookie'
import { createCookieAccessor } from './cookieAccess'
import type { CookieAccessor } from './cookieAccess'

const COOKIE_NAME = 'test_cookie'

describe('cookieAccess', () => {
let accessor: CookieAccessor

describe('document.cookie fallback', () => {
beforeEach(() => {
Object.defineProperty(globalThis, 'cookieStore', { value: undefined, configurable: true, writable: true })
accessor = createCookieAccessor({})
registerCleanupTask(() => {
deleteCookie(COOKIE_NAME)
delete (globalThis as any).cookieStore
})
})

it('should set a cookie', async () => {
await accessor.set(COOKIE_NAME, 'hello', 60_000)
expect(getCookie(COOKIE_NAME)).toBe('hello')
})

it('should get all cookies', async () => {
await accessor.set(COOKIE_NAME, 'value1', 60_000)
const values = await accessor.getAll(COOKIE_NAME)
expect(values).toContain('value1')
})

it('should delete a cookie', async () => {
await accessor.set(COOKIE_NAME, 'to_delete', 60_000)
await accessor.delete(COOKIE_NAME)
expect(getCookie(COOKIE_NAME)).toBeUndefined()
})
})
})
73 changes: 73 additions & 0 deletions packages/core/src/browser/cookieAccess.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { dateNow } from '../tools/utils/timeUtils'
import type { CookieOptions } from './cookie'
import { getCookies, setCookie, deleteCookie } from './cookie'
import type { CookieStore } from './browser.types'

export interface CookieAccessor {
getAll(name: string): Promise<string[]>
set(name: string, value: string, expireDelay: number, options?: CookieOptions): Promise<void>
delete(name: string, options?: CookieOptions): Promise<void>
}

interface CookieStoreWindow {
cookieStore?: CookieStore
}

export function createCookieAccessor(cookieOptions: CookieOptions): CookieAccessor {
const store = (globalThis as CookieStoreWindow).cookieStore

if (store) {
return createCookieStoreAccessor(store, cookieOptions)
}

return createDocumentCookieAccessor()
}

function createCookieStoreAccessor(store: CookieStore, cookieOptions: CookieOptions): CookieAccessor {
return {
async getAll(name: string): Promise<string[]> {
const cookies = await store.getAll(name)
return cookies.map((cookie) => cookie.value)
},

async set(name: string, value: string, expireDelay: number): Promise<void> {
const expires = new Date(dateNow() + expireDelay)
await store.set({
name,
value,
domain: cookieOptions.domain,
path: '/',
expires,
sameSite: cookieOptions.crossSite ? 'none' : 'strict',
secure: cookieOptions.secure,
partitioned: cookieOptions.partitioned,
})
},

async delete(name: string): Promise<void> {
await store.delete({
name,
domain: cookieOptions.domain,
path: '/',
})
},
}
}

function createDocumentCookieAccessor(): CookieAccessor {
return {
getAll(name: string): Promise<string[]> {
return Promise.resolve(getCookies(name))
},

set(name: string, value: string, expireDelay: number, options?: CookieOptions): Promise<void> {
setCookie(name, value, expireDelay, options)
return Promise.resolve()
},

delete(name: string, options?: CookieOptions): Promise<void> {
deleteCookie(name, options)
return Promise.resolve()
},
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import type { Subscription } from '@datadog/browser-core'
import { ONE_MINUTE, deleteCookie, setCookie } from '@datadog/browser-core'
import type { Clock } from '@datadog/browser-core/test'
import { mockClock } from '@datadog/browser-core/test'
import { mockRumConfiguration } from '../../test'
import type { Clock } from '../../test'
import { mockClock } from '../../test'
import type { Configuration } from '../domain/configuration'
import type { Subscription } from '../tools/observable'
import { ONE_MINUTE } from '../tools/utils/timeUtils'
import { deleteCookie, setCookie } from './cookie'
import type { CookieStoreWindow } from './cookieObservable'
import { WATCH_COOKIE_INTERVAL_DELAY, createCookieObservable } from './cookieObservable'

const COOKIE_NAME = 'cookie_name'
const COOKIE_DURATION = ONE_MINUTE

function mockConfiguration(): Configuration {
return {} as Configuration
}

describe('cookieObservable', () => {
let subscription: Subscription
let originalSupportedEntryTypes: PropertyDescriptor | undefined
Expand All @@ -27,7 +32,7 @@ describe('cookieObservable', () => {
})

it('should notify observers on cookie change', async () => {
const observable = createCookieObservable(mockRumConfiguration(), COOKIE_NAME)
const observable = createCookieObservable(mockConfiguration(), COOKIE_NAME)

const cookieChangePromise = new Promise((resolve) => {
subscription = observable.subscribe(resolve)
Expand All @@ -53,7 +58,7 @@ describe('cookieObservable', () => {

it('should notify observers on cookie change when cookieStore is not supported', () => {
Object.defineProperty(window, 'cookieStore', { get: () => undefined, configurable: true })
const observable = createCookieObservable(mockRumConfiguration(), COOKIE_NAME)
const observable = createCookieObservable(mockConfiguration(), COOKIE_NAME)

let cookieChange: string | undefined
subscription = observable.subscribe((change) => (cookieChange = change))
Expand All @@ -66,7 +71,7 @@ describe('cookieObservable', () => {

it('should not notify observers on cookie change when the cookie value as not changed when cookieStore is not supported', () => {
Object.defineProperty(window, 'cookieStore', { get: () => undefined, configurable: true })
const observable = createCookieObservable(mockRumConfiguration(), COOKIE_NAME)
const observable = createCookieObservable(mockConfiguration(), COOKIE_NAME)

setCookie(COOKIE_NAME, 'foo', COOKIE_DURATION)

Expand Down
60 changes: 60 additions & 0 deletions packages/core/src/browser/cookieObservable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { Configuration } from '../domain/configuration'
import { setInterval, clearInterval } from '../tools/timer'
import { Observable } from '../tools/observable'
import { ONE_SECOND } from '../tools/utils/timeUtils'
import { findCommaSeparatedValue } from '../tools/utils/stringUtils'
import type { CookieStore } from './browser.types'
import { addEventListener, DOM_EVENT } from './addEventListener'

export interface CookieStoreWindow {
cookieStore?: CookieStore
}

export type CookieObservable = ReturnType<typeof createCookieObservable>

export function createCookieObservable(configuration: Configuration, cookieName: string) {
const detectCookieChangeStrategy = (window as CookieStoreWindow).cookieStore
? listenToCookieStoreChange(configuration)
: watchCookieFallback

return new Observable<string | undefined>((observable) =>
detectCookieChangeStrategy(cookieName, (event) => observable.notify(event))
)
}

function listenToCookieStoreChange(configuration: Configuration) {
return (cookieName: string, callback: (event: string | undefined) => void) => {
const listener = addEventListener(
configuration,
(window as CookieStoreWindow).cookieStore!,
DOM_EVENT.CHANGE,
(event) => {
// Based on our experimentation, we're assuming that entries for the same cookie cannot be in both the 'changed' and 'deleted' arrays.
// However, due to ambiguity in the specification, we asked for clarification: https://github.com/WICG/cookie-store/issues/226
const changeEvent =
event.changed.find((event) => event.name === cookieName) ||
event.deleted.find((event) => event.name === cookieName)
if (changeEvent) {
callback(changeEvent.value)
}
}
)
return listener.stop
}
}

export const WATCH_COOKIE_INTERVAL_DELAY = ONE_SECOND

function watchCookieFallback(cookieName: string, callback: (event: string | undefined) => void) {
const previousCookieValue = findCommaSeparatedValue(document.cookie, cookieName)
const watchCookieIntervalId = setInterval(() => {
const cookieValue = findCommaSeparatedValue(document.cookie, cookieName)
if (cookieValue !== previousCookieValue) {
callback(cookieValue)
}
}, WATCH_COOKIE_INTERVAL_DELAY)

return () => {
clearInterval(watchCookieIntervalId)
}
}
20 changes: 20 additions & 0 deletions packages/core/src/domain/session/sessionLock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { mockable } from '../../tools/mockable'
import { SESSION_STORE_KEY } from './storeStrategies/sessionStoreStrategy'

let lockPromise: Promise<void> | undefined

export function withNativeSessionLock(fn: () => void | Promise<void>): void {
if (navigator?.locks) {
void navigator.locks.request(SESSION_STORE_KEY, fn)
return
}
// Chain async callbacks to prevent interleaving
if (!lockPromise) {
lockPromise = Promise.resolve()
}
lockPromise = lockPromise.then(fn, fn)
}

export function withSessionLock(fn: () => void | Promise<void>): void {
mockable(withNativeSessionLock)(fn)
}
Loading