Merge pull request #142 from robonen/docs

feat(storage): enhance useStorageAsync with cross-instance sync and e…
This commit is contained in:
2026-06-10 15:13:40 +07:00
committed by GitHub
25 changed files with 3725 additions and 199 deletions
@@ -0,0 +1,213 @@
import { describe, expect, it } from 'vitest';
import {
decodeCookieValue,
encodeCookieName,
encodeCookieValue,
getCookieValue,
parseCookieString,
serializeCookie,
} from './index';
describe('encodeCookieValue', () => {
it('passes plain values through unchanged', () => {
expect(encodeCookieValue('dark')).toBe('dark');
expect(encodeCookieValue('abc-123_~.!*')).toBe('abc-123_~.!*');
});
it('keeps RFC 6265-allowed punctuation readable', () => {
expect(encodeCookieValue('a=b&c:d/e?f@g')).toBe('a=b&c:d/e?f@g');
expect(encodeCookieValue('#$+<>[]^`{|}')).toBe('#$+<>[]^`{|}');
});
it('encodes characters cookies cannot contain', () => {
expect(encodeCookieValue('a;b')).toBe('a%3Bb');
expect(encodeCookieValue('a b')).toBe('a%20b');
expect(encodeCookieValue('a,b')).toBe('a%2Cb');
expect(encodeCookieValue('a"b')).toBe('a%22b');
expect(encodeCookieValue('a\\b')).toBe('a%5Cb');
expect(encodeCookieValue('50%')).toBe('50%25');
});
it('round-trips through decodeCookieValue', () => {
const value = 'json:{"a": "b, c"}; 50% off\\done';
expect(decodeCookieValue(encodeCookieValue(value))).toBe(value);
});
});
describe('decodeCookieValue', () => {
it('decodes percent escapes', () => {
expect(decodeCookieValue('a%3Bb')).toBe('a;b');
expect(decodeCookieValue('%D0%BF%D1%80%D0%B8%D0%B2%D0%B5%D1%82')).toBe('привет');
});
it('returns malformed escapes as-is instead of throwing', () => {
expect(decodeCookieValue('100%')).toBe('100%');
expect(decodeCookieValue('%E0%A4%A')).toBe('%E0%A4%A');
});
it('unwraps DQUOTE-wrapped values', () => {
expect(decodeCookieValue('"dark"')).toBe('dark');
expect(decodeCookieValue('"a%3Bb"')).toBe('a;b');
expect(decodeCookieValue('"')).toBe('"');
});
});
describe('encodeCookieName', () => {
it('passes typical names through unchanged', () => {
expect(encodeCookieName('session-id')).toBe('session-id');
expect(encodeCookieName('user_pref.v2')).toBe('user_pref.v2');
});
it('encodes separators and whitespace', () => {
expect(encodeCookieName('user name')).toBe('user%20name');
expect(encodeCookieName('a=b')).toBe('a%3Db');
expect(encodeCookieName('a;b')).toBe('a%3Bb');
});
it('escapes parentheses', () => {
expect(encodeCookieName('a(b)')).toBe('a%28b%29');
});
});
describe('parseCookieString', () => {
it('parses an empty string to an empty map', () => {
expect(parseCookieString('').size).toBe(0);
});
it('parses multiple cookies', () => {
const map = parseCookieString('theme=dark; sid=abc123');
expect(map.get('theme')).toBe('dark');
expect(map.get('sid')).toBe('abc123');
});
it('decodes names and values by default', () => {
const map = parseCookieString('user%20name=a%3Bb');
expect(map.get('user name')).toBe('a;b');
});
it('keeps raw values with an identity decoder', () => {
const map = parseCookieString('sid=a%3Bb', value => value);
expect(map.get('sid')).toBe('a%3Bb');
});
it('keeps the first occurrence per name (most specific path wins)', () => {
const map = parseCookieString('dup=specific; dup=generic');
expect(map.get('dup')).toBe('specific');
});
it('keeps the value intact when it contains "="', () => {
const map = parseCookieString('token=a=b=c');
expect(map.get('token')).toBe('a=b=c');
});
it('unwraps double-quoted values via the default decoder, keeps them raw with identity', () => {
expect(parseCookieString('quoted="hello"').get('quoted')).toBe('hello');
// The raw layer is verbatim — matching what the Cookie Store API reports
expect(parseCookieString('quoted="hello"', value => value).get('quoted')).toBe('"hello"');
});
it('parses nameless cookies under the empty name', () => {
expect(parseCookieString('=bare').get('')).toBe('bare');
expect(parseCookieString('bare').get('')).toBe('bare');
});
});
describe('getCookieValue', () => {
it('finds a cookie by name', () => {
expect(getCookieValue('theme=dark; sid=abc', 'sid')).toBe('abc');
expect(getCookieValue('theme=dark; sid=abc', 'theme')).toBe('dark');
});
it('returns null for a missing cookie or empty string', () => {
expect(getCookieValue('theme=dark', 'missing')).toBeNull();
expect(getCookieValue('', 'any')).toBeNull();
});
it('decodes the value by default and supports an identity decoder', () => {
expect(getCookieValue('sid=a%3Bb', 'sid')).toBe('a;b');
expect(getCookieValue('sid=a%3Bb', 'sid', value => value)).toBe('a%3Bb');
});
it('matches encoded stored names against the raw name', () => {
expect(getCookieValue('user%20name=v', 'user name')).toBe('v');
});
it('keeps the first occurrence and unwraps quotes via the default decoder', () => {
expect(getCookieValue('dup=specific; dup=generic', 'dup')).toBe('specific');
expect(getCookieValue('quoted="hello"', 'quoted')).toBe('hello');
expect(getCookieValue('quoted="hello"', 'quoted', value => value)).toBe('"hello"');
});
it('matches parseCookieString semantics for edge cases', () => {
for (const cookie of ['token=a=b=c', '=bare', 'bare', 'a=1; ; b=2']) {
const map = parseCookieString(cookie);
for (const [name, value] of map)
expect(getCookieValue(cookie, name)).toBe(value);
}
});
});
describe('serializeCookie', () => {
it('always emits Path and SameSite', () => {
expect(serializeCookie('theme', 'dark')).toBe('theme=dark; Path=/; SameSite=Lax');
});
it('emits the provided attributes', () => {
expect(serializeCookie('sid', 'abc', {
path: '/app',
domain: 'example.com',
maxAge: 3600,
secure: true,
sameSite: 'strict',
})).toBe('sid=abc; Path=/app; Domain=example.com; SameSite=Strict; Max-Age=3600; Secure');
});
it('serializes expires from a Date and from epoch milliseconds', () => {
const date = new Date('2030-01-01T00:00:00Z');
expect(serializeCookie('a', 'b', { expires: date })).toContain(`Expires=${date.toUTCString()}`);
expect(serializeCookie('a', 'b', { expires: date.getTime() })).toContain(`Expires=${date.toUTCString()}`);
});
it('emits Partitioned together with Secure', () => {
expect(serializeCookie('a', 'b', { secure: true, partitioned: true })).toContain('; Secure; Partitioned');
});
it('expresses deletion as a non-positive Max-Age', () => {
expect(serializeCookie('a', '', { maxAge: 0 })).toBe('a=; Path=/; SameSite=Lax; Max-Age=0');
});
it('throws on a non-encoded name', () => {
expect(() => serializeCookie('a b', 'v')).toThrow(TypeError);
expect(() => serializeCookie('a=b', 'v')).toThrow(TypeError);
expect(() => serializeCookie('a;b', 'v')).toThrow(TypeError);
});
it('throws on a non-encoded value (attribute injection)', () => {
expect(() => serializeCookie('a', 'v; Path=/admin')).toThrow(TypeError);
expect(() => serializeCookie('a', 'v w')).toThrow(TypeError);
expect(() => serializeCookie('a', 'a,b')).toThrow(TypeError);
expect(serializeCookie('a', '"quoted"')).toContain('a="quoted"');
});
it('throws on SameSite=None without Secure', () => {
expect(() => serializeCookie('a', 'b', { sameSite: 'none' })).toThrow(TypeError);
expect(serializeCookie('a', 'b', { sameSite: 'none', secure: true })).toContain('SameSite=None');
});
it('throws on Partitioned without Secure', () => {
expect(() => serializeCookie('a', 'b', { partitioned: true })).toThrow(TypeError);
});
it('throws when name plus value exceeds 4096 bytes (the "=" is not counted)', () => {
expect(() => serializeCookie('big', 'x'.repeat(4094))).toThrow(RangeError);
expect(serializeCookie('big', 'x'.repeat(4093))).toContain('big=');
});
});
+308
View File
@@ -0,0 +1,308 @@
/**
* Write-time cookie attributes (the `Set-Cookie` half of RFC 6265).
*
* Cookies expose no way to read attributes back — these only describe how a
* cookie is written (and must be repeated to overwrite or delete it, since a
* cookie's identity is its `name` + `domain` + `path`).
*/
export interface CookieAttributes {
/**
* The path the cookie is scoped to.
*
* @default '/'
*/
path?: string;
/**
* The domain the cookie is scoped to. Omitted = host-only cookie.
*/
domain?: string;
/**
* Lifetime in seconds. Takes precedence over `expires` in browsers when both
* are present (RFC 6265 §4.1.2.2). Non-positive values expire the cookie.
*/
maxAge?: number;
/**
* Expiry as a `Date` or Unix epoch **milliseconds**. Omitting both `maxAge`
* and `expires` creates a session cookie.
*/
expires?: Date | number;
/**
* Only send the cookie over HTTPS.
*/
secure?: boolean;
/**
* `SameSite` attribute. `'none'` requires `secure: true` — browsers silently
* drop the cookie otherwise, so {@link serializeCookie} fails loudly instead.
*
* @default 'lax'
*/
sameSite?: 'lax' | 'strict' | 'none';
/**
* Partition the cookie by top-level site (CHIPS). Requires `secure: true`.
*
* @default false
*/
partitioned?: boolean;
}
/**
* Browsers commonly enforce RFC 6265's minimum of 4096 bytes for `name=value`;
* anything longer is silently dropped, so {@link serializeCookie} throws instead.
*/
const MAX_COOKIE_BYTES = 4096;
// RFC 6265 cookie-octet allows these characters, but encodeURIComponent escapes
// them — un-escape to keep values readable and js-cookie-compatible:
// %23 # | %24 $ | %26 & | %2B + | %2F / | %3A : | %3C < | %3D = | %3E > |
// %3F ? | %40 @ | %5B [ | %5D ] | %5E ^ | %60 ` | %7B { | %7C | | %7D }
const ALLOWED_VALUE_ESCAPES = /%(?:2[346BF]|3[AC-F]|40|5[BDE]|60|7[BCD])/g;
// Cookie names are RFC 2616 tokens; restore the token characters
// encodeURIComponent escapes: %23 # | %24 $ | %26 & | %2B + | %5E ^ | %60 ` | %7C |
const ALLOWED_NAME_ESCAPES = /%(?:2[346B]|5E|60|7C)/g;
/**
* @name encodeCookieValue
* @category Browsers
* @description Percent-encodes only the characters a cookie value cannot contain
* per RFC 6265 (controls, whitespace, `"` `,` `;` `\` and `%` itself), leaving
* everything else readable. Compatible with js-cookie's default write converter.
*
* @param {string} value The raw cookie value
* @returns {string} The encoded cookie value
*
* @example
* encodeCookieValue('a=b; c'); // 'a=b%3B%20c'
*
* @since 0.0.5
*/
export function encodeCookieValue(value: string): string {
return encodeURIComponent(value).replaceAll(ALLOWED_VALUE_ESCAPES, decodeURIComponent);
}
/**
* @name decodeCookieValue
* @category Browsers
* @description Decodes a cookie value: unwraps an RFC 6265 DQUOTE-wrapped
* value (the quotes are transport dressing, not payload), then decodes
* percent-escapes. Malformed escapes (e.g. third-party cookies that never
* used percent-encoding) are returned as-is instead of throwing.
*
* @param {string} value The encoded cookie value
* @returns {string} The decoded cookie value
*
* @example
* decodeCookieValue('a%3Bb'); // 'a;b'
*
* @example
* decodeCookieValue('"dark"'); // 'dark'
*
* @since 0.0.5
*/
export function decodeCookieValue(value: string): string {
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"'))
value = value.slice(1, -1);
try {
return value.replaceAll(/(?:%[\dA-F]{2})+/gi, decodeURIComponent);
}
catch {
return value;
}
}
/**
* @name encodeCookieName
* @category Browsers
* @description Percent-encodes the characters a cookie name cannot contain
* (cookie names are RFC 2616 tokens). Typical names — letters, digits, `-`,
* `_`, `.` — pass through unchanged. `(` and `)` are escaped as `%28`/`%29`.
*
* @param {string} name The raw cookie name
* @returns {string} The encoded cookie name
*
* @example
* encodeCookieName('user name'); // 'user%20name'
*
* @since 0.0.5
*/
export function encodeCookieName(name: string): string {
return encodeURIComponent(name)
.replaceAll(ALLOWED_NAME_ESCAPES, decodeURIComponent)
.replaceAll(/[()]/g, c => c === '(' ? '%28' : '%29');
}
/**
* @name parseCookieString
* @category Browsers
* @description Parses a `document.cookie`-style string (`'a=1; b=2'`) into a
* `Map` of decoded names to values. Keeps the **first** occurrence per name —
* browsers order cookies most-specific-path first, so the first one is the one
* a server would use. The raw value is passed to `decode` verbatim (including
* any wrapping quotes) so it matches what the Cookie Store API would report;
* the default decoder unwraps quotes and percent-escapes.
*
* @param {string} cookie The cookie string to parse
* @param {(value: string) => string} [decode=decodeCookieValue] Value decoder; pass the identity function to keep raw encoded values
* @returns {Map<string, string>} Decoded `name -> value` pairs
*
* @example
* parseCookieString('theme=dark; sid=a%3Bb'); // Map { 'theme' => 'dark', 'sid' => 'a;b' }
*
* @since 0.0.5
*/
export function parseCookieString(cookie: string, decode: (value: string) => string = decodeCookieValue): Map<string, string> {
const result = new Map<string, string>();
for (const pair of cookie.split('; ')) {
if (!pair)
continue;
const separator = pair.indexOf('=');
// Nameless cookies ('=value' or bare 'value') parse under the empty name.
const rawName = separator === -1 ? '' : pair.slice(0, separator);
const rawValue = separator === -1 ? pair : pair.slice(separator + 1);
const name = decodeCookieValue(rawName);
if (!result.has(name))
result.set(name, decode(rawValue));
}
return result;
}
/**
* @name getCookieValue
* @category Browsers
* @description Looks up a single cookie in a `document.cookie`-style string
* without building a full map — same first-occurrence and verbatim-raw-value
* semantics as {@link parseCookieString}, but allocation-free for misses and
* cheap for hot paths (reactive reads, polling).
*
* @param {string} cookie The cookie string to search
* @param {string} name The raw (decoded) cookie name to look up
* @param {(value: string) => string} [decode=decodeCookieValue] Value decoder; pass the identity function to keep the raw encoded value
* @returns {string | null} The cookie value, or `null` when absent
*
* @example
* getCookieValue('theme=dark; sid=a%3Bb', 'sid'); // 'a;b'
*
* @since 0.0.5
*/
export function getCookieValue(cookie: string, name: string, decode: (value: string) => string = decodeCookieValue): string | null {
for (const pair of cookie.split('; ')) {
if (!pair)
continue;
const separator = pair.indexOf('=');
const rawName = separator === -1 ? '' : pair.slice(0, separator);
// Stored names are encoded — compare directly first (encoding is the
// identity for typical names), decode only on mismatch.
if (rawName !== name && decodeCookieValue(rawName) !== name)
continue;
return decode(separator === -1 ? pair : pair.slice(separator + 1));
}
return null;
}
const INVALID_COOKIE_NAME = /[\s;=]/;
// RFC 6265 cookie-octet (optionally DQUOTE-wrapped): a raw ';' or whitespace
// in a value silently truncates it and injects attributes into the cookie.
const VALID_COOKIE_VALUE = /^"?[\u0021\u0023-\u002B\u002D-\u003A\u003C-\u005B\u005D-\u007E]*"?$/;
// Encoded names/values are ASCII, but the inputs are not guaranteed to be —
// any string under this length cannot exceed MAX_COOKIE_BYTES even at the
// 3-bytes-per-UTF-16-unit worst case, so the common path never pays for an
// actual byte count.
const SAFE_COOKIE_LENGTH = Math.floor(MAX_COOKIE_BYTES / 3);
let textEncoder: TextEncoder | undefined;
function exceedsCookieBytes(pair: string): boolean {
if (pair.length <= SAFE_COOKIE_LENGTH)
return false;
textEncoder ??= new TextEncoder();
return textEncoder.encode(pair).length > MAX_COOKIE_BYTES;
}
/**
* @name serializeCookie
* @category Browsers
* @description Builds a `document.cookie` assignment string from an
* **already-encoded** name and value (see {@link encodeCookieName} /
* {@link encodeCookieValue}) plus {@link CookieAttributes}. `Path` and
* `SameSite` are always emitted explicitly. Fails loudly on combinations
* browsers silently drop: `SameSite=None` or `Partitioned` without `Secure`,
* and `name=value` over 4096 UTF-8 bytes.
*
* @param {string} name The encoded cookie name
* @param {string} value The encoded cookie value
* @param {CookieAttributes} [attributes={}] Write-time attributes
* @returns {string} The string to assign to `document.cookie`
*
* @example
* document.cookie = serializeCookie('theme', 'dark', { maxAge: 3600 });
* // 'theme=dark; Path=/; SameSite=Lax; Max-Age=3600'
*
* @example
* // Deleting reuses the same identity attributes with a non-positive maxAge
* document.cookie = serializeCookie('theme', '', { maxAge: 0 });
*
* @since 0.0.5
*/
export function serializeCookie(name: string, value: string, attributes: CookieAttributes = {}): string {
const {
path = '/',
domain,
maxAge,
expires,
secure = false,
sameSite = 'lax',
partitioned = false,
} = attributes;
if (INVALID_COOKIE_NAME.test(name))
throw new TypeError(`[serializeCookie] invalid cookie name "${name}" — encode it with encodeCookieName first`);
if (!VALID_COOKIE_VALUE.test(value))
throw new TypeError(`[serializeCookie] invalid cookie value for "${name}" — encode it with encodeCookieValue first`);
if (sameSite === 'none' && !secure)
throw new TypeError('[serializeCookie] SameSite=None requires Secure — browsers reject the cookie otherwise');
if (partitioned && !secure)
throw new TypeError('[serializeCookie] Partitioned requires Secure — browsers reject the cookie otherwise');
// Browsers enforce the RFC 6265bis limit on name + value, excluding the '='
if (exceedsCookieBytes(`${name}${value}`))
throw new RangeError(`[serializeCookie] cookie "${name}" exceeds ${MAX_COOKIE_BYTES} bytes — browsers drop it silently`);
let cookie = `${name}=${value}; Path=${path}`;
if (domain)
cookie += `; Domain=${domain}`;
cookie += `; SameSite=${sameSite.charAt(0).toUpperCase()}${sameSite.slice(1)}`;
if (maxAge !== undefined)
cookie += `; Max-Age=${Math.floor(maxAge)}`;
if (expires !== undefined)
cookie += `; Expires=${(expires instanceof Date ? expires : new Date(expires)).toUTCString()}`;
if (secure)
cookie += '; Secure';
if (partitioned)
cookie += '; Partitioned';
return cookie;
}
+1
View File
@@ -1,4 +1,5 @@
export * from './animationLifecycle';
export * from './cookies';
export * from './domStyle';
export * from './focusGuard';
export * from './focusScope';