Skip to content

登录信号 (cookie) ​

v2 把跨标签 / 跨子域的「登录已完成」待办收敛到一种机制——同 site cookie 信号。SDK 在登录成功路径写一份 __tripo_login_signal__ cookie,由同根域实例竞争认领,业务侧通过 auth.signal.onLogin(handler) 订阅;它不是向所有 tab fan-out 的广播。本页讲清楚协议、架构、token 兜底、SSR 安全、退出登录与典型 handler 写法。

v1 用 BroadcastChannel 在同 origin 三页之间通信,跨 origin 业务自己写 postMessage。v2 改用 cookie 一统天下,原因:

  • 跨子域天然可达:Domain=.tripo3d.ai 让所有子域共享同一份 cookie;登录页(www.tripo3d.ai)的事件可以被业务子域(studio.tripo3d.ai)感知,业务方零协议工作
  • OAuth 回跳带得上:SameSite=Lax 让 OIDC 完成页的 cookie 在 IdP redirect 链路上不丢
  • 跨 tab 可见:cookie 全局可见,不依赖 BroadcastChannel 或 window 引用;由其中一方认领,不保证每个 tab 都派发
  • COOP/COEP 友好:popup 失去 opener 引用后 BroadcastChannel 仍可用,但 cookie 是更朴素的兜底
  • API 简洁:业务方只看到 onLogin(handler) 一个入口,订阅 / 取消订阅 / 跨标签竞争消费 / 跨子域送达由 SDK 处理

协议 ​

cookie 全字段一览:

字段值
名__tripo_login_signal__
Domain自动探测到的可注册根域(如 .tripo3d.ai);localhost / IP 退化为 host-only
Path/
SameSiteLax(让 OAuth 回跳能带上)
Max-Age60s(由常量 LOGIN_SIGNAL_MAX_AGE_SECONDS 控制)
Securehttps 下加;http 本地调试不加(浏览器会拒写)
valuebase64url(JSON(SignalCookiePayload))

SignalCookiePayload 在线协议:

ts
interface SignalCookiePayload {
  v: 1 | 2;
  provider: string;     // 'code' | 'password' | OIDC provider id ('google' / 'apple' / ...)
  ts: number;           // Date.now()
  params?: string;      // base64url(JSON(LoginParams)),可选
  d?: 1;                // v2 才出现:发布方已完成本 tab 派发,可选
}

普通待派发信号使用 v:1。One Tap 这类当场完成登录、且已有订阅者的入口会在本 tab 派发后写 v:2,d:1:新版 SDK 将其视为已派发,旧版 SDK 因只识别 v1 而安全忽略,从而阻断同根域混合版本期间的二次埋点。代价是旧端不会针对这条已完成登录再执行本地收尾;这是 SDK 选择“宁可旧端漏一次刷新,也不双报”。d 字段名压到一个字母,是因为 cookie 值会经 base64url 膨胀。完成登录时若尚无订阅者,仍写 v:1 并保留内存 pending,等待后续订阅或轮询认领。

业务侧拿到的 LoginSignal 是解码后的形态:

ts
interface LoginSignal {
  readonly v: 1;
  readonly provider: string;
  readonly ts: number;
  readonly params?: LoginParams;  // 已解码回原值;编码失败时不出现
}

写入时机 ​

SDK 在以下 5 个位置写一份 cookie:

路径写入端provider
邮箱验证码登录auth.login.submitCode 成功'code'
邮箱密码登录auth.login.submitPassword 成功'password'
密码重置auth.reset.submitNewPassword 成功'password'
OIDC 完成页auth.oidc.handleComplete()URL provider query(如 'google')
Google One Tapauth.oidc.submitIdToken() 成功(promptGoogleOneTap 内部调用)'google'(提交时传入的 provider)

OIDC edge(账号合并 / 补信息)流程本身不单独写 cookie:它最终跳回 OIDC 完成页,由上表的 handleComplete() 统一写入。

密码重置(auth.reset.submitNewPassword)成功后会写 cookie(provider: 'password')——recovery 验证码校验通过时 ory 已把当前会话认证为登录态,重置密码即完成一次登录,故与其它登录路径一致写信号、由容器收尾直接进入登录态。退出登录走 auth.logout(),它会 clearSignal() 主动失效 cookie,再派发 onLogout 事件(见下文「退出登录」章节)。

业务侧 API ​

订阅 login / logout:

ts
import type { LoginPayload, LogoutPayload, Unsubscribe } from '@tripo3d/auth';

const unsubLogin: Unsubscribe = auth.signal.onLogin(async ({ signal, context }) => {
  // SDK 已在派发前确保 token 就绪;这里 token.get() 直接拿得到值
  await fetchCurrentUser();
  if (context.isRedirect) {
    // 业务页冷启路径(从 LoginPage / OIDC redirect 跳回来的)
  }
});

const unsubLogout: Unsubscribe = auth.signal.onLogout(({ signal }) => {
  // SDK 已在派发前 clear 了 token;这里 peek() 是 undefined
  clearLocalUserStore();
});

// 组件卸载时
unsubLogin();
unsubLogout();

handler 签名:

ts
type OnLoginHandler  = (payload: LoginPayload)  => void | Promise<void>;
type OnLogoutHandler = (payload: LogoutPayload) => void | Promise<void>;

interface LoginPayload  { signal: LoginSignal;  context: LoginContext; }
interface LogoutPayload { signal: LogoutSignal; }
interface LoginContext  { isRedirect: boolean; }
  • 同步抛错 / 异步 reject 都被 SDK 静默吞掉并走 report(业务方传入的 onError),一个 handler 抛错不影响别的
  • handler 可以注册多个;每收到一份信号都会派发给所有当前已注册的 handler
  • client only:onLogin / onLogout 在 SSR 上下文(无 window)直接返回 no-op unsubscribe,安全无副作用

context.isRedirect 的语义 ​

值触发路径业务用法
false本 tab 主动派发(popup / dialog 模式 useConsumeSignal 命中 cookie)主流程,按常规更新业务态
true业务页冷启时检测到 pendingSignals 自动补偿派发(独立页 / OIDC redirect 回跳)通常等价于 false,可选地跳过"登录成功动画"等同页面交互

判定规则集中在 SDK 单点(emitLogin 路径 = false,pendingSignals 补偿 = true),业务侧无须传或猜。

架构:单 bus + 两层 ​

                      bus (createEventBus<{ login: LoginPayload; logout: LogoutPayload }>)
                                              ▲
                  ┌───────────────────────────┼───────────────────────────┐
                  │                           │                           │
        sugar emitLogin              sugar emitLogout             sugar onLogin / onLogout
        (仅实际交付时消费          (token.clear +               (bus.on + 仅 login 路径
         matching pending +           bus.emit)                    pendingSignals 补偿派发)
         ensureTokenReady + bus.emit)
                  │                           │
                  └─────────  ↑  ──────────────┘
                          UI 层 useConsumeSignal / useReadSignal
                          (polling cookie,命中后调 emitLogin)

第 1 层:SignalApi(业务面,core/signal/api.ts) ​

auth.signal namespace 暴露 3 组方法:

类型方法说明
原子readSignal()读 cookie,不删;脏数据 / 过期返回 null 但不清理
原子readSignalRecord()同上,但额外返回「已派发」标记:{ signal, dispatched }
原子writeSignal(input)写 cookie(submitCode / submitPassword / handleComplete 用);input.dispatched 为真时打上标记
原子clearSignal()主动清掉 cookie
Login sugaremitLogin(signal)UI 命中 cookie 后调它;派发前自动 await token.refresh() 兜底
Login sugarpublishLogin(signal)给无轮询容器兜底的入口用(One Tap):有订阅者时写带标记 cookie + 立即派发;无订阅者时写未标记 cookie + 保留 pending,等待后续认领
Login sugarconsumeSignal()读 + 清 cookie,返回 { signal, shouldEmit };shouldEmit 为假表示已被发布方派发过,只需本地收尾
Login sugaronLogin(handler)业务订阅 = bus.on + 有效窗口内对每个晚注册订阅者的 pendingSignals 定向补偿
Logout sugaremitLogout()派发前同步 token.clear();由 auth.logout() 内部调用
Logout sugaronLogout(handler)业务订阅 logout 事件;无 pendingSignals 补偿(登出不持久化)

事件总线底层是 @tripo3d/utils 的 createEventBus——它已经处理好 handlers Map、快照后 forEach(防 handler 内 off 跳过相邻订阅)、try/catch 隔离一个 handler 抛错不影响其他。SDK 注册时套一层 wrapper 补齐:

  1. async handler 的 reject 用 Promise.resolve(...).catch(...) 兜住,避免变成 unhandled rejection
  2. 抛错走 TripoAuthError({ code: 'onlogin_handler_failed', ... }) + reporter(业务可监控),替代 utils 默认的 console.error

Token 状态承诺 ​

sugar派发前 SDK 做什么handler 同步入口的 token 状态
emitLoginif (!token.peek()) await token.refresh() —— refresh 失败仅 report 不阻断派发通常已就绪;refresh 失败时仍为 undefined(业务方可在 handler 里再 fallback)
emitLogouttoken.clear()(同步)一定是 undefined

token.refresh 内部有并发去重 + 冷却窗口,业务 handler 里再 await token.refresh() 不会重复打后端,作为容错保留是安全的。

pendingSignals 补偿 ​

createSignalApi 闭包构造时立刻读 cookie record 并清掉;只有未标记信号会按 provider + ts 进入 pendingSignals Map。多个尚未实际送达的信号可以并存,轮询认领另一份 cookie 不会覆盖已有的内存后备。每次写入前会同步清理已过期项,且最多保留最近 20 条,超过时淘汰最早记录;这不创建后台定时器:

ts
const pendingSignals = new Map<string, LoginSignal>();
if (safeWindow()) {
  const record = readSignalRecord();
  if (record) {
    clearSignal();
    if (!record.dispatched) {
      retainPendingSignal(record.signal);
    }
  }
}

意图:业务页冷启时若 cookie 还在(OIDC redirect 或 LoginPage 跳回来的),以及运行期 publishLogin 完成时尚无订阅者,即便业务 onLogin 注册得晚(plugin / onMounted / 懒加载路由),也能通过 microtask 补偿拿到每份待送达信号。每个晚注册订阅者都有自己的补偿任务;cookie 被轮询认领或空 bus.emit 不等于已经送达,因此本实例会保留/建立同 identity 的 pending,直到实际向有效订阅者广播或定向调用。主派发会先登记同一信号的 delivery claim,令在 token await 中的补偿让位,防止二次派发。首个真正执行的补偿只清除仍匹配该 pending 的未标记 cookie,阻断后续轮询,但保留内存 pending 并只调用当前 wrapped handler:

ts
onLogin(handler) {
  if (!safeWindow()) return noop;
  const wrapped = wrapWithReporter(handler);
  const unsub = bus.on('login', wrapped);
  const pendingSnapshots = getFreshPendingSignals();
  for (const pending of pendingSnapshots) {
    queueMicrotask(() => {
      if (bus.has('login', wrapped)) {
        void emitLoginCompensate(wrapped, pending);
        // ensureTokenReady → 确认同 identity 的 pending 未被主路径消费
        // → 确认没有被 main delivery claim 认领
        // → 首次补偿清匹配 cookie → 保留 pending → 只定向调用当前 wrapped
      }
    });
  }
  return unsub;
}

何时失效:

  • 主路径 emitLogin 实际向仍有效订阅者广播同一 identity 时只移除对应的 Map 项;空 bus.emit 不会移除任何 pending,已排队补偿也会让位给已认领的主派发
  • 首个成功补偿会清除仍匹配该信号的未标记 cookie,避免后续轮询再次派发;内存 pending 继续保留,供有效窗口内后注册的订阅者分别补偿
  • 自身 ts 距今超过 cookie Max-Age(默认 60s)——写入路径、getFreshPendingSignals() / 同 identity 查询都会懒清对应 Map 项。避免冷启 redirect 路径下永不触发主路径 emit 时晚注册的 handler 拿到"幽灵登录"
  • 保留数量超过 20 条时淘汰最早记录;这是防御异常高频的无订阅者发布,正常登录流不应触及该边界
  • SDK 实例销毁

已知行为边界:

  • 每份 pending 信号在 60s 窗口内持续有效,直到其主路径实际送达或过期。每个晚注册 handler 对每份 pending 各收到一次定向补偿,先前已补偿的 handler 不会因后续注册再次触发 ✓
  • 业务方 onLogin(同一 handler) 重复注册会产生多份 listener、handler 被触发多次。业务方应保证一个 handler 只注册一次(SDK 不做幂等去重,避免引入 WeakMap)
  • emitLoginCompensate 内 await ensureTokenReady() 后会再次检查 bus.has('login', wrapped)、同 identity pending 与主派发 claim:await 窗口内 handler 已 unsub,或同一信号已由主路径送达时均不再派发

第 2 层:UI composables(@tripo3d/auth/ui) ​

ts
// LoginDialog 用:dialog 本身就是登录目的地,可以消费 cookie
useConsumeSignal(auth, (signal) => {
  dialog.close(opener)
})
// 内部:
// onMounted setInterval 内
//   const sig = auth.signal.readSignal()
//   if (sig) {
//     auth.signal.clearSignal()      // 清 cookie 防同 tab 重复消费
//     auth.signal.emitLogin(sig)     // sugar 派发;内部会 await token.refresh 兜底
//     onSignal(sig)                  // 本地回调(关 dialog)
//   }

// LoginPage 独立页用:cookie 要原样留给业务页消费
useReadSignal(auth, (signal) => {
  window.location.assign(returnTo)
})
// 内部:
// onMounted setInterval 内
//   const sig = auth.signal.readSignal()
//   if (sig) onSignal(sig)           // 只调本地回调,不动 cookie、不 emit

两个 composable 都是 (auth, onSignal) 签名,onUnmounted 清 timer。

分层解决了什么 ​

  • polling 移出 SDK → 不开 dialog 就不轮询,无空跑;业务方更可控
  • useConsumeSignal 通过 emitLogin sugar 替 SDK 派发 → 业务侧通过 auth.signal.onLogin 注册的 handler 在 dialog 打开期间也能收到信号
  • useReadSignal 不动 cookie → 独立登录页跳回业务时 cookie 完好,业务页 SignalApi 构造期 readSignalRecord() 捕获未标记信号 → 晚注册的 onLogin 分别定向补偿

v2 早期版本里独立页直接走「读+删」路径,cookie 被独立页吃掉,业务页拿不到 → PC / 移动端独立页登录后业务侧 onLogin 永不触发。当前架构通过 useReadSignal 的「只读不删」彻底修掉这个问题。

退出登录 ​

auth.logout() 是完整的退出闭环:

ts
// SDK 内部
this._logout = async () => {
  await runLogout({ api, onSuccess: () => this.signal.emitLogout() });
};

// runLogout: 调 ory updateLogoutFlow 失效 session → clearSignal() 清 cookie → onSuccess
// emitLogout: token.clear() → bus.emit('logout', { signal })

业务侧只需要订阅 onLogout:

ts
auth.signal.onLogout(({ signal }) => {
  // 此时 token.peek() === undefined(SDK 派发前已 clear)
  clearLocalUserStore();
  router.push('/');
});

button.onClick = () => auth.logout();  // 触发完整链路

注意:

  • emitLogout 不持久化、不跨 tab —— 其他 tab 自然会在下次 token.refresh 拿到 401 时感知
  • LogoutSignal 当前只有 { ts, v };将来扩展(如 reason: 'user' | 'session_expired')走可选字段,向后兼容

一次信号一次处理 ​

readSignal() 是纯读,不会自己清 cookie。「读+删」的语义由调用方组合:

ts
const sig = auth.signal.readSignal()
if (sig) {
  auth.signal.clearSignal()
  // 处理 sig
}

useConsumeSignal composable 已内置 read + clear + emit 组合,业务方不必直接调原子方法。

跨 tab 多个 polling 会尽力竞争同一份全局待办 cookie:先抢到 clearSignal 的 tab 通常触发 handler,其他 tab 下一轮读到 null;但 cookie 的 read + clear 不具备跨子域原子 compare-and-claim,两个实例同时读取时无法给出数学意义的全局 exactly-once 保证。若 One Tap 发布方已在本 tab 派发并写入 v:2,d:1,新版其他 tab 即使先读到也只清 cookie,不触发 onLogin;旧版会直接忽略 v2。其他 tab 通过共享 session/token 在后续请求中自然感知登录态;若产品要求所有已打开 tab 立即刷新用户态,或需要跨子域严格全局一次,应由宿主提供服务端 claim/权威 relay,而不是依赖 cookie。

错误隔离 ​

handler 抛错被严格隔离,不会污染派发循环:

ts
function safeRun(invoke: () => unknown): void {
  try {
    Promise.resolve(invoke()).catch(err => report(toReportable(err)));
  } catch (err) {
    report(toReportable(err));
  }
}
  • Promise.resolve 包一层:同步抛错和异步 reject 统一兜
  • 多 handler 场景下一个抛错不影响其他 handler 派发(底层 EventBus 复制 handlers 后 forEach,每个 handler 各自 try/catch + Promise.resolve 兜底,相互独立)
  • 失败通过 TripoAuthOptions.onError 上报为 onlogin_handler_failed(code 字段);不传 onError 时回落 console.warn

跨子域 / 跨标签场景图解 ​

场景 1:业务子域 + 独立登录页(OIDC redirect) ​

[Tab A: studio.tripo3d.ai]                          [Tab A: www.tripo3d.ai/login]
auth.signal.onLogin(handler) 已注册
showLogin → startOidc → SDK 推 redirect → location.assign(idpUrl)
                                                    用户完成 OIDC
                                                    回到业务传入的 completeUrl(绝对 URL)?...
                                                    OidcCompletePage.handleComplete()
                                                      → writeSignal(...)
                                                      → location.assign(returnTo='https://studio.tripo3d.ai/...')
[Tab A: studio.tripo3d.ai]   ◄────────────────────────
business page 冷启动
  new TripoAuth({...}) → createSignalApi 构造时立刻 readSignalRecord() → 未标记信号进入 pendingSignals Map + clearSignal()
  app onMounted 注册 onLogin(handler)
  → queueMicrotask: ensureTokenReady + 校验同 identity pending + 定向补偿当前 handler,context.isRedirect=true

场景 2:popup 模式 + 跨 tab 感知 ​

[Main Tab: studio.tripo3d.ai]                       [OIDC Popup: completeUrl(绝对 URL)]
auth.signal.onLogin(handler) 已注册(业务侧)
LoginDialog 打开 → useConsumeSignal 起 polling
startOidc → SDK 推 popup → window.open(idpUrl, 'popup', ...)
                                                    用户完成 OIDC → 回到完成页
                                                    OidcCompletePage.handleComplete()
                                                      → writeSignal(...)
                                                      → window.close()
[Main Tab]
200ms 后 useConsumeSignal polling 命中 cookie
  auth.signal.consumeSignal() → 返回 { signal, shouldEmit } 并删 cookie 防重复
  shouldEmit=true → auth.signal.emitLogin(signal) → ensureTokenReady + 派发给业务 onLogin handler(context.isRedirect=false)
  本地 onSignal(signal) → dialog.close(opener)

场景 3:同 tab 内邮箱登录 ​

[Tab A: studio.tripo3d.ai]
auth.signal.onLogin(handler) 已注册(业务侧)
LoginDialog 打开 → useConsumeSignal 起 polling
showLogin → LoginDialog 里调 auth.login.submitCode(...) 成功
  → writeSignal({ provider: 'code' })
200ms 后 useConsumeSignal polling 命中 cookie:
  consumeSignal → shouldEmit=true → emitLogin (ensureTokenReady) → 业务 onLogin handler 触发,context.isRedirect=false
  本地 onSignal → 关 dialog

典型 handler 写法 ​

ts
// app 启动时一次性注册全局 handler
auth.signal.onLogin(async ({ signal, context }) => {
  // 1. SDK 已经在派发前做了 token.refresh 兜底,token.get() 直接可用
  //    可选:保留 refresh 作为容错(内部并发去重,不会重复打后端)
  await auth.token.refresh();

  // 2. 初始化用户信息
  await fetchCurrentUser();

  // 3. context.isRedirect 区分冷启 vs 当前页登录(按需)
  if (!context.isRedirect) {
    closeAuthDialog();
    playLoginSuccessAnimation();
  }

  // 4. 埋点
  trackEvent('login_success', {
    provider: signal.provider,
    is_redirect: context.isRedirect,
    source: (signal.params as { source?: string } | undefined)?.source,
  });
});

auth.signal.onLogout(({ signal }) => {
  clearLocalUserStore();
  router.push('/');
  trackEvent('logout', { ts: signal.ts });
});

要点:

  • handler 是 async,SDK 用 Promise.resolve 包了
  • 类型上 LoginParams = unknown,业务侧自己 narrow:约定一份 LoginParams 业务类型,每次 submitCode / startOidc 传时都符合
  • 不要在 handler 里再调 auth.login.* 触发新登录——会写新 cookie 但 polling 在 useConsumeSignal 内已经 clear cookie 了,行为不可预测
  • 同一 handler 不要重复 onLogin(myHandler) —— 会产生多份 listener、被触发多次

注意事项 ​

本地调试 ​

本地 dev 通常跑在 localhost:5173 / 127.0.0.1,不在 .tripo3d.ai 子域下:

  • cookie 自动探测的根域是 host-only(不写 Domain)
  • 本地调试登录流不能跨 tab 感知
  • 推荐:在 hosts 配 127.0.0.1 dev.tripo3d.ai,开 https + 让 dev server 跑在 dev.tripo3d.ai 子域

时钟漂移 ​

readSignal 有二次过期校验 Date.now() - payload.ts > 60000。客户端时钟严重错乱会导致:

  • 时钟快 → cookie 看起来已过期,被 drop
  • 时钟慢 → cookie 看起来更新鲜,正常消费

实践中很少撞到,无需特别处理;真撞了业务侧加埋点即可。

SSR ​

  • createSignalApi 闭包内 safeWindow() 兜底,服务端实例化不会触发 cookie 操作;pendingSignals 一律为空 Map
  • onLogin / onLogout 在无 window 上下文直接返回 no-op unsubscribe,不注册任何 listener;业务方可放心在通用代码里调
  • 客户端 hydrate 后构造新 SignalApi,再走一次 readSignalRecord()——若 cookie 存在且未标记为已派发,则按 identity 进入 pendingSignals Map,等待业务 onLogin 晚注册补偿

API 参考 ​

方法签名说明
auth.signal.readSignal() => LoginSignal | null原子:读 cookie,不删。脏数据 / 过期返回 null 但不清理
auth.signal.readSignalRecord() => SignalRecord | null原子:只读 cookie,并额外返回 dispatched 标记
auth.signal.writeSignal(input: WriteSignalInput) => void原子:写 cookie;submitCode / submitPassword / handleComplete 用
auth.signal.clearSignal() => void原子:主动清掉 cookie;logout / useConsumeSignal 用
auth.signal.consumeSignal() => ConsumedSignal | nullLogin sugar:读并清 cookie,返回 shouldEmit 告知轮询容器是否仍需派发
auth.signal.emitLogin(signal: LoginSignal) => Promise<void>Login sugar:派发前 await token.refresh() 兜底 + bus.emit;UI composable 命中 cookie 后调
auth.signal.publishLogin(signal: LoginSignal) => Promise<void>Login sugar:One Tap 使用;根据当前订阅状态写派发标记并立即派发或保留 pending
auth.signal.onLogin(handler: OnLoginHandler) => UnsubscribeLogin sugar:业务订阅 + 有效窗口内逐订阅者 pendingSignals 补偿;SSR 返回 no-op
auth.signal.emitLogout() => voidLogout sugar:派发前同步 token.clear() + bus.emit;由 auth.logout() 内部调用
auth.signal.onLogout(handler: OnLogoutHandler) => UnsubscribeLogout sugar:订阅退出事件;SSR 返回 no-op
decodeLoginParams(encoded: string | undefined) => LoginParams | undefined导出工具:从 URL params query 还原原值;UI 子路径 LoginPage 用它解 query
encodeLoginParams(params: LoginParams | undefined) => string | undefined导出工具:把 params 编进 URL;通常由 SDK 内部用,导出便于业务 SSR 场景手工拼

下一步 ​

基于 MIT 协议发布(内部使用)