@agentaily/persistence
Agentaily 各产品共享的偏好持久化原语 —— 一个会优雅降级的跨子域键 / 值存储, 外加架在它之上的一层类型化单值绑定。零依赖、无框架、SSR 安全。
createStorage({ backend, cookieDomain, keyPrefix, cookieMaxAge }) → PreferenceStorage
persistentState({ key, defaultValue, storage, decode?, encode? }) → { get, set, remove }- 默认跨子域。 当 host 允许时写一个
.agentaily.comcookie,所以在一个子域上设的 偏好能在另一个子域上读到。可逐 store 用cookieDomain: null退出、改用 host 作用域的 cookie —— 见下方 安全 一节。 - 优雅降级。 一次性解析出最佳后端:cookie → localStorage → host 作用域的 cookie → 内存。每个候选都必须挺过一次写 / 读探测,所以隐私模式 / 被禁用的存储永远 不会抛错。
- SSR 安全。 在非浏览器环境里它解析为一个 no-op store(
get→null),于是 消费者回退到它的默认值;没有任何东西会碰到缺失的全局对象。
安全 —— 敏感状态必须是 host 作用域(绝不用父域 cookie)
铁律:平台会话 / token / 任何敏感凭据绝不经
.agentaily.com(父域)cookie 落地。
默认的 cookieDomain 是 .agentaily.com —— 一个父域 cookie,每一个 *.agentaily.com host 都能读,包括用户生成内容(UGC)子域。这对非敏感偏好 (主题、locale)是正确的 —— 跨子域共享它们正是目的所在 —— 但对任何敏感东西,它都是一条 凭据泄漏路径。
所以:
- 非敏感偏好(主题 / locale): 保留默认(省略
cookieDomain)。它们搭着父域 cookie、按设计跨子域共享。 - 任何敏感东西(会话、token、secret): 传
cookieDomain: null拿一个 host 作用域的 cookie(无Domain属性 → 只有设它的那个确切 host 能读)。更好的 做法是,根本别把凭据存进 JS 可读的 cookie —— Agentaily 的平台会话是一个存在localStorage里的Authorization: BearerJWT(源隔离、从不跨子域共享),所以它 根本不碰这个 store。
// non-sensitive — shared across *.agentaily.com (default)
const prefs = createStorage(); // Domain=.agentaily.com cookie
// sensitive — host-scoped, sibling subdomains can't read it
const secret = createStorage({ cookieDomain: null }); // no Domain attribute这个包为什么存在
主题切换和 locale 切换都需要「跨子域记住一个用户选择」—— 同一套机制。它过去逐字节地 内嵌在三处:@agentaily/theme、@agentaily/i18n,以及 @agentaily/design-system 的运行时(全都从已退役的 @agentaily/web-kit 移植过去)。本包就是这三者现在共同依托的 单一真相源,于是一处修复或新功能落一次地即可。
它刻意无框架(无 React peer),在依赖图里位于 @agentaily/theme 和 @agentaily/i18n 之下。
安装(在 monorepo 内)
// consumer package.json
"dependencies": {
"@agentaily/persistence": "workspace:*"
}消费为 TypeScript 源码(无构建步)—— exports 指向 ./src/index.ts,和 @agentaily/aml、@agentaily/theme、@agentaily/i18n 一样。
用法
import { createStorage, persistentState } from "@agentaily/persistence";
// 1 — resolve a backend (once per concern). Keys are namespaced by keyPrefix.
const storage = createStorage(); // defaults: auto backend, .agentaily.com cookie, "agentaily:" prefix
// 2 — bind one typed key with a default + validation.
const theme = persistentState<"system" | "light" | "dark">({
key: "theme",
defaultValue: "system",
storage,
decode: (raw) => (["system", "light", "dark"].includes(raw) ? (raw as never) : undefined),
});
theme.get(); // "system" (or the persisted choice)
theme.set("dark"); // persists across subdomains
theme.remove(); // back to defaultAPI
| 导出 | 是什么 |
|---|---|
createStorage(config?) | 解析出一个 PreferenceStorage(cookie → localStorage → memory → no-op)。 |
persistentState(options) | 把一个 key 绑定到一个带默认值 + 可选 encode/decode 的类型化值。 |
isBrowser() | SSR 守卫(需要仅浏览器分支的消费者复用)。 |
cookieStore, localStore, memoryStore, nullStore, guard, probe | 用于构建自定义 store 的底层 store 原语。 |
StorageConfig:{ backend?: "auto" | "cookie" | "local", cookieDomain?, keyPrefix?, cookieMaxAge? }。 cookieDomain:省略 → .agentaily.com(跨子域);null → host 作用域(无 Domain, 敏感状态必需 —— 见 安全);一个字符串 → 那个确切的域名。
测试
pnpm --filter @agentaily/persistence test(或在本目录 npx vitest run)—— node 环境,23 个测试,覆盖 store 原语(memory / null / guard / probe)、 persistentState(默认 / round-trip / decode 拒绝 / 自定义 encode / remove)、 createStorage 的 SSR 降级,以及 —— 经一个会捕获 document.cookie 写入的假浏览器 —— cookie 作用域契约(默认 .agentaily.com 父域共享 vs. cookieDomain: null host 作用域,外加 localhost 兜底)。零外部依赖;今天独立运行。完整的浏览器后端(真实 localStorage round-trip)由各消费者的 jsdom 测试套端到端地演练(@agentaily/theme、 @agentaily/i18n)。
目录结构
src/
index.ts 对外统一导出(barrel)
createStorage.ts 后端解析(cookie → localStorage → memory → no-op)
persistentState.ts 类型化单值绑定
cookie.ts 原始 cookie 读 / 写 + cookieStore
stores.ts localStore / memoryStore / nullStore / guard / probe
types.ts StorageBackend / StorageConfig / PreferenceStorage
env.ts isBrowser() SSR 守卫
test/ persistence.test.ts(node)