Skip to content

@agentaily/persistence

Agentaily 各产品共享的偏好持久化原语 —— 一个会优雅降级的跨子域键 / 值存储, 外加架在它之上的一层类型化单值绑定。零依赖、无框架、SSR 安全。

createStorage({ backend, cookieDomain, keyPrefix, cookieMaxAge }) → PreferenceStorage
persistentState({ key, defaultValue, storage, decode?, encode? })  → { get, set, remove }
  • 默认跨子域。 当 host 允许时写一个 .agentaily.com cookie,所以在一个子域上设的 偏好能在另一个子域上读到。可逐 store 用 cookieDomain: null 退出、改用 host 作用域的 cookie —— 见下方 安全 一节。
  • 优雅降级。 一次性解析出最佳后端:cookie → localStorage → host 作用域的 cookie → 内存。每个候选都必须挺过一次写 / 读探测,所以隐私模式 / 被禁用的存储永远 不会抛错。
  • SSR 安全。 在非浏览器环境里它解析为一个 no-op store(getnull),于是 消费者回退到它的默认值;没有任何东西会碰到缺失的全局对象。

铁律:平台会话 / 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: Bearer JWT(源隔离、从不跨子域共享),所以它 根本不碰这个 store。
ts
// 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 内)

jsonc
// consumer package.json
"dependencies": {
  "@agentaily/persistence": "workspace:*"
}

消费为 TypeScript 源码(无构建步)—— exports 指向 ./src/index.ts,和 @agentaily/aml@agentaily/theme@agentaily/i18n 一样。

用法

ts
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 default

API

导出是什么
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)