UNPKG

@bitfiber/rx

Version:

Reactive State and Async Workflow Management Library based on RxJS

267 lines (239 loc) 9.21 kB
import {isString, extend, getDocument, getWindow, stub} from '@bitfiber/utils'; import {Subject, Observable, map, filter, share} from 'rxjs'; import {startWithDefined} from '../../operators'; import {KeyValueSource} from '../'; import {parseJson} from '../internal/internal'; /** * Represents the optional parameters that can be used when setting a cookie */ export interface CookieParams { /** * Specifies the URL path that must exist in the requested URL for the cookie to be valid */ path?: string; /** * Specifies the domain for which the cookie is valid */ domain?: string; /** * Specifies the expiration date for the cookie. If not set, the cookie is considered * a session cookie */ expires?: Date; /** * Specifies the maximum age of the cookie in seconds. Overrides `expires` if both are set */ maxAge?: number; /** * Indicates whether the cookie should only be sent over secure protocols like HTTPS */ secure?: boolean; /** * Specifies the `SameSite` policy for the cookie, which controls how cookies are sent * with cross-site requests */ sameSite?: 'strict' | 'lax'; } /** * Represents a value stored in a cookie * @template T - The type of the value stored in the cookie */ export interface CookieValue<T> { /** * The value to be stored in the cookie */ value: T; } /** * Combines cookie parameters with the value to be stored in a cookie * @template T - The type of the value stored in the cookie */ export type CookieData<T> = CookieParams & CookieValue<T>; /** * Holds a singleton instance of the `Cookie` class */ let source: Cookie | undefined; /** * Creates and returns a singleton instance of the `Cookie` class and ensures that only one * instance is created. If the instance already exists, it returns the existing one * * @template T - Represents an object that includes both the value and parameters for the cookie. * Defaults to `CookieData<string | undefined>` */ export function cookie<T extends CookieData<any> = CookieData<string | undefined>>(): Cookie<T> { return (source as Cookie<T>) ??= new Cookie<T>(); } /** * Provides access to browser cookies as a key-value storage. * * The `Cookie` class implements the `KeyValueSource` interface, allowing interaction with * browser cookies using key-value semantics. It provides methods for retrieving, setting, * observing, and removing cookies * * @template T - Represents an object that includes both the value and parameters for the cookie. * Defaults to `CookieData<string | undefined>` */ export class Cookie<T extends CookieData<any> = CookieData<string | undefined>> implements KeyValueSource<T> { /** * A reference to the global `document` object * @readonly */ private readonly doc = getDocument(); /** * A reference to the global `window` object * @readonly */ private readonly win = getWindow(); /** * Emits a string representing the key whenever there is a change (e.g., set, remove) * in local storage. This allows observers to react to changes in specific keys * @readonly */ private readonly subject = new Subject<string>(); private readonly channel!: BroadcastChannel; private lastCookie = this.doc.cookie; /** * Creates a singleton instance */ constructor() { if (source) { return (source as Cookie<T>); } else { // eslint-disable-next-line @typescript-eslint/no-this-alias source = this; this.channel = getBroadcastChannel('BfCookieChannel'); this.expandCookie(); } } /** * Returns the value of the cookie associated with the given key * and parses it as JSON. If the cookie does not exist, returns `undefined` * @param key - The specific key under which the cookie value is stored */ get(key: string): T { const cookie = this.doc.cookie.replace(/^\s+/g, '').split(';').find(row => row.startsWith(key)); const value = cookie?.split('=')[1]; return <T>{value: isString(value) ? parseJson<any>(decodeURIComponent(value)) : undefined}; } /** * Sets a cookie with the specified key and value, stringified before being added to the cookie. * Additional parameters, such as cookie options (e.g., `expires`, `path`), * can be provided as part of the `data` object * * @param key - The specific key under which the value will be stored * @param data - An object containing the new value to store, as well as optional cookie parameters */ set(key: string, data: T): void { const {value, path, domain, expires, maxAge, secure, sameSite} = data; const _value = encodeURIComponent(JSON.stringify(value)); const _path = `; path=${path || '/'}`; const _domain = domain ? `; domain=${domain}` : ''; const _expires = expires ? `; expires=${expires.toUTCString()}` : ''; const _maxAge = maxAge ? `; max-age=${maxAge}` : ''; const _secure = secure === false ? '' : '; secure'; const _sameSite = sameSite ? `; samesite=${sameSite}` : ''; this.doc.cookie = `${key}=${_value}${_expires}${_maxAge}${_path}${_domain}${_secure}${_sameSite}`; this.subject.next(key); } /** * Removes the cookie associated with the given key. Optionally, you can * provide `CookieParams` to specify additional options, such as the path or domain, to ensure * the correct cookie is removed * * @param key - The specific key (name) of the cookie to be removed * @param [params] - Optional parameters that can be used to specify the cookie's path, domain, etc. */ remove(key: string, params?: CookieParams): void { const {hostname} = this.win.location; const expires = new Date(1970, 1, 1); this.set(key, extend({path: '/'}, params || {domain: hostname}, { value: '', expires, maxAge: -1, }) as T); } /** * Creates and returns an observable that emits value changes of the cookie * associated with the specified key. This allows reactive monitoring of the cookie value * @param key - The specific key (name) under which the cookie value is stored */ observe(key: string): Observable<T> { return this.subject.pipe( filter(eKey => key === eKey), map(key => this.get(key)), share(), startWithDefined(() => this.get(key)), ); } /** * Destroys the reference to the instance and frees any associated resources */ destroy(): void { this.subject.complete(); this.channel.close(); source = undefined; } /** * Expands the native browser cookie functionality with custom logic */ private expandCookie(): void { // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; const originalCookie = 'originalCookie'; const docPrototype = typeof Document === 'function' ? Document.prototype : {}; const nativeCookieDescriptor = Object.getOwnPropertyDescriptor(docPrototype, 'cookie') || {}; // Listen cookieChange messages from other same-origin tabs/frames this.channel.onmessage = event => { const {key, newCookie} = event.data.detail; this.lastCookie = newCookie; this.subject.next(key); }; // Expand original document.cookie Object.defineProperty(docPrototype, originalCookie, nativeCookieDescriptor); Object.defineProperty(docPrototype, 'cookie', { enumerable: true, configurable: true, get() { return this[originalCookie]; }, set(fullCookie) { this[originalCookie] = fullCookie; const newCookie = this[originalCookie]; const key = self.getKey(fullCookie); if (key && newCookie !== self.lastCookie) { try { // Dispatch cookieChange messages to other same-origin tabs/frames self.channel.postMessage({ detail: {key, newCookie, lastCookie: self.lastCookie}, }); } finally { self.lastCookie = newCookie; } } }, }); } /** * Extracts and returns the key name under which a value is stored from a full cookie string * @param fullCookie - The full cookie string from which to extract the key */ private getKey(fullCookie: string): string | undefined { const cookies = fullCookie.replace(/^\s+/g, '').split(';'); for (let i = 0; i < cookies.length; i++) { const key = cookies[i].split('=')[0]; if (key && !['path', 'domain', 'expires', 'max-age', 'secure', 'samesite'].includes(key)) { return key; } } return undefined; } } /** * Creates and returns either a `BroadcastChannel` instance or a stub service * if `BroadcastChannel` is not supported * @param channelName - The name of the broadcast channel to create */ function getBroadcastChannel(channelName: string): BroadcastChannel { return typeof BroadcastChannel === 'function' ? new BroadcastChannel(channelName) : <BroadcastChannel>{close: stub, postMessage: stub}; }