accounts
Version:
Tempo Accounts SDK
214 lines (195 loc) • 6.87 kB
text/typescript
import { createStore, del, get, set } from 'idb-keyval'
import { Json } from 'ox'
import type { MaybePromise } from '../internal/types.js'
/** Pluggable storage adapter for persisting provider state. */
export type Storage = {
getItem: <value>(name: string) => MaybePromise<value | null>
setItem: (name: string, value: unknown) => MaybePromise<void>
removeItem: (name: string) => MaybePromise<void>
}
/** Creates a storage adapter from a custom implementation, optionally scoping all keys under a prefix. */
export function from(storage: Storage, options: from.Options = {}): Storage {
const key = options.key ?? 'tempo'
const prefix = `${key}.`
return {
getItem: (name) => storage.getItem(`${prefix}${name}`),
setItem: (name, value) => storage.setItem(`${prefix}${name}`, value),
removeItem: (name) => storage.removeItem(`${prefix}${name}`),
}
}
export declare namespace from {
type Options = {
/** Key prefix for all stored items. @default "tempo" */
key?: string | undefined
}
}
/**
* Combines multiple storage adapters into one. Reads return the first
* non-null result; writes propagate to all storages (failures are isolated
* via `Promise.allSettled`).
*/
export function combine(...storages: readonly Storage[]): Storage {
return {
async getItem<value>(name: string) {
const results = await Promise.allSettled(storages.map((x) => x.getItem<value>(name)))
const result = results.find((x) => x.status === 'fulfilled' && x.value != null)
if (result?.status !== 'fulfilled') return null
return result.value as value
},
async removeItem(name) {
await Promise.allSettled(storages.map((x) => x.removeItem(name)))
},
async setItem(name, value) {
await Promise.allSettled(storages.map((x) => x.setItem(name, value)))
},
}
}
/** Creates a `document.cookie`-backed storage adapter. Uses `SameSite=None; Secure` with a 1-year expiry. Deep objects are flattened into individual cookies to stay within the 4KB-per-cookie browser limit. */
export function cookie(options: cookie.Options = {}): Storage {
function getRaw(name: string): string | undefined {
return document.cookie.split('; ').find((x) => x.startsWith(`${name}=`))
}
function setRaw(name: string, value: string) {
document.cookie = `${name}=${value};path=/;samesite=None;secure;max-age=31536000`
}
function removeRaw(name: string) {
document.cookie = `${name}=;max-age=-1;path=/`
}
function flatten(prefix: string, value: unknown, result: [string, string][] = []) {
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) flatten(`${prefix}[${i}]`, value[i], result)
// Store length so we know how many indices to reconstruct.
result.push([`${prefix}.__length`, Json.stringify(value.length)])
} else if (value !== null && typeof value === 'object') {
for (const [k, v] of Object.entries(value as Record<string, unknown>))
flatten(`${prefix}.${k}`, v, result)
} else result.push([prefix, Json.stringify(value)])
return result
}
function unflatten(prefix: string): unknown {
// Check for a direct (leaf) cookie first.
const direct = getRaw(prefix)
if (direct) {
try {
return Json.parse(direct.substring(prefix.length + 1))
} catch {
return null
}
}
// Check if this is an array (has a __length cookie).
const lengthCookie = getRaw(`${prefix}.__length`)
if (lengthCookie) {
const length = Json.parse(lengthCookie.substring(`${prefix}.__length`.length + 1)) as number
const result: unknown[] = []
for (let i = 0; i < length; i++) result.push(unflatten(`${prefix}[${i}]`))
return result
}
// Collect all sub-keys (object children use `.`, array children use `[`).
const dotPrefix = `${prefix}.`
const bracketPrefix = `${prefix}[`
const children = document.cookie
.split('; ')
.filter((x) => x.startsWith(dotPrefix) || x.startsWith(bracketPrefix))
if (children.length === 0) return null
const result: Record<string, unknown> = {}
for (const entry of children) {
const key = entry.substring(dotPrefix.length, entry.indexOf('='))
const segment = key.split(/[.[]/)[0]!
if (segment === '__length') continue
if (!(segment in result)) result[segment] = unflatten(`${dotPrefix}${segment}`)
}
return result
}
return from(
{
getItem(name) {
return unflatten(name) as any
},
setItem(name, value) {
// Remove existing keys before writing.
this.removeItem(name)
for (const [k, v] of flatten(name, value)) setRaw(k, v)
},
removeItem(name) {
removeRaw(name)
for (const entry of document.cookie.split('; '))
if (entry.startsWith(`${name}.`) || entry.startsWith(`${name}[`))
removeRaw(entry.substring(0, entry.indexOf('=')))
},
},
options,
)
}
export declare namespace cookie {
type Options = from.Options
}
/** Creates an IndexedDB-backed storage adapter. Stores raw values (no JSON serialization). */
export function idb(options: idb.Options = {}): Storage {
const store = typeof indexedDB !== 'undefined' ? createStore('tempo', 'store') : undefined
return from(
{
async getItem(name) {
const value = await get(name, store)
if (value === null) return null
return value
},
async setItem(name, value) {
await set(name, value, store)
},
async removeItem(name) {
await del(name, store)
},
},
options,
)
}
export declare namespace idb {
type Options = from.Options
}
/** Creates a `localStorage`-backed storage adapter. */
export function localStorage(options: localStorage.Options = {}): Storage {
return from(
{
getItem(name) {
const value = globalThis.localStorage.getItem(name)
if (value === null) return null
try {
return Json.parse(value)
} catch {
return null
}
},
setItem(name, value) {
globalThis.localStorage.setItem(name, Json.stringify(value))
},
removeItem(name) {
globalThis.localStorage.removeItem(name)
},
},
options,
)
}
export declare namespace localStorage {
type Options = from.Options
}
/** Creates an in-memory storage adapter. Useful for SSR and tests. */
export function memory(options: memory.Options = {}): Storage {
const store = new Map<string, unknown>()
return from(
{
getItem(name) {
return (store.get(name) as any) ?? null
},
setItem(name, value) {
store.set(name, value)
},
removeItem(name) {
store.delete(name)
},
},
options,
)
}
export declare namespace memory {
type Options = from.Options
}