Appearance
宿主能力、业务事件与公开会话
@axewo/fin-micro/sdk 定义子应用协议,@axewo/fin-micro/core 创建宿主。本文介绍同窗口、显式授权的函数桥。它不是 RPC,也不是针对恶意 JavaScript 的隔离沙箱;真正的跨窗口通信见 iframe RPC。
三种通信职责
| 需求 | 使用接口 | 是否有返回值 |
|---|---|---|
| 请求宿主完成一项业务 | bridge.callHost / page.callHost | Promise,可区分失败 |
| 广播已发生的业务事实 | page.emit、page.on、host.bus | 同步通知,无响应协议 |
| 同步公开身份、权限及登录状态 | bridge.getSession / subscribeSession | 冻结快照与订阅 |
不要用事件模拟有返回值的调用;不要把 Token 放进事件、路由或公开会话;不要把整个宿主、请求客户端或业务服务对象注入子应用。
正式认证能力与失效协调
生成模板使用 /sdk 的 AuthCapabilities,其中 getToken 沿用 createTokenCapability 的取消及版本复核;beginAuthRequest/reportAuthFailure 由 createAuthFailureCapabilities 注册。它们是正式技术能力,不需要业务自造事件总线或复制契约。下文的业务能力示例用于说明 callHost,不代替模板的正式认证接线。
每次可信 API 请求发送前先签发不透明票据,再读取当前 Token。收到 401 或 Token 未登录时只上报原票据,不传子应用自行填写的 revision。宿主使用可信 context.appId、原对象身份和私有凭据版本核验;失效回调必须同步清除身份并推进版本,因此并发 401 只撤销一次,旧请求不能退出新登录。真实失效不受 beforeLeave 阻止;正常用户退出先显示一次系统 FDialog 确认,确认后直接清理,不逐页重复询问。
票据不含 Token,不缓存或持久化,不能复制/序列化,也不支持 iframe RPC。卸载停止上报,不自动刷新登录或重放请求。独立 Vue 应用通过真实后端适配器建立身份,嵌入身份来自宿主;两者使用相同的注入权限 API。详情见独立认证、多语言与权限。
1. 定义类型化能力契约
契约只描述业务参数与结果,不包含宿主的 context 参数。建议宿主与子应用共享一份类型模块。
ts
// contracts.ts:仅共享类型,不共享宿主服务实现。
export interface BusinessCapabilities {
'records.read': (id: string) => Promise<{ id: string; title: string }>
'auth.getAccessToken': () => string | null
}调用的返回值统一为 Promise<Awaited<返回类型>>,因此同步能力也必须 await。不指定具体契约时默认类型较宽,不能获得同等的名称、参数和结果校验。TypeScript 契约不替代运行时校验。
2. 宿主注册处理器与应用白名单
下面是可独立理解的宿主初始化示例;接口地址、Token 来源与业务鉴权需替换为项目实现。
ts
import { createMicroHost } from '@axewo/fin-micro/core'
import type { BusinessCapabilities } from './contracts'
// 仅保存在宿主私有闭包;登录/刷新凭据时由业务认证模块更新。
let currentAccessToken: string | null = null
const host = createMicroHost<BusinessCapabilities>([
{ appId: 'records', name: '记录管理', entry: 'http://localhost:5174' },
{ appId: 'reports', name: '报表', entry: 'http://localhost:5175' },
], {
callTimeoutMs: 30_000,
session: { authenticated: false, permissions: [] },
capabilities: {
'records.read': {
apps: ['records', 'reports'],
async handler(context, id) {
// 类型正确不代表输入可信;仍需校验消息/业务参数。
if (typeof id !== 'string' || !id.trim()) throw new Error('记录标识无效')
const response = await fetch(`/api/records/${encodeURIComponent(id)}`, {
signal: context.signal,
})
if (!response.ok) throw new Error('读取失败')
const data: unknown = await response.json()
if (!data || typeof data !== 'object' ||
!('id' in data) || typeof data.id !== 'string' ||
!('title' in data) || typeof data.title !== 'string') {
throw new Error('结果格式无效')
}
// 若后面需要提交业务副作用,应先复核调用是否仍有效。
if (!context.isActive()) throw new Error('调用已失效')
return { id: data.id, title: data.title }
},
},
'auth.getAccessToken': {
// 只向可信且确有需要的子应用开放凭据。
apps: ['records'],
handler: () => currentAccessToken,
},
},
onError(error) {
// 生产环境应脱敏上报,避免输出凭据和业务负载。
console.error('宿主监听或清理失败', error)
},
})
// 登录成功后,业务认证代码更新私有凭据及公开身份:
function applyLogin(token: string) {
currentAccessToken = token
host.updateSession({
authenticated: true,
user: { id: 'user-1', name: '示例用户' },
permissions: ['records:read'],
})
}注册行为如下:
- 未提供
capabilities或未登记名称:默认拒绝,返回UNKNOWN_CAPABILITY,不会按点分路径查找宿主成员。auth.getAccessToken只是普通字符串能力名。 apps省略:所有已注册应用可调用;apps: []:全部拒绝;指定列表:仅对应应用可调用,否则FORBIDDEN。- 创建宿主时复制处理器及白名单,之后修改原配置不会动态改变授权。没有运行时能力注册/撤销接口。
- 能力注册表允许只实现契约中的部分键;编译通过不保证运行时已注册,必要时先协商。
context.appId来自宿主绑定的桥,不读取业务参数中的appId。服务端仍须独立鉴权,不能把这个工程约束当作安全沙箱。
handler 的可信上下文
| 字段 | 含义 |
|---|---|
appId | 发起调用的已注册应用 |
tab | 页级调用时捕获的冻结 MicroTab 快照;应用级调用为 undefined |
signal | 宿主为本次调用创建的协作取消信号,可传给 fetch 等 |
isActive() | 本次调用未中止,且宿主、桥及绑定页面仍有效 |
isActive() 不是“该页当前被选中”。隐藏但仍存在的缓存页可以有效;tab 也不是始终指向当前活动页的动态引用。不要依据业务负载的身份字段决定调用来源。
3. 应用级调用与页级调用
ts
import { getMicroBridge, HostCallError } from '@axewo/fin-micro/sdk'
import type { BusinessCapabilities } from './contracts'
const bridge = getMicroBridge<BusinessCapabilities>()
if (!bridge) throw new Error('独立运行时请配置独立业务服务,不能伪造宿主成功响应')
// 应用级调用:用于启动阶段、全局请求层等;handler context.tab 为 undefined。
const record = await bridge.callHost('records.read', 'record-1')
// 绑定实际页面生命周期;pageKey 应来自已打开页面的 tab.key。
const pageKey = bridge.getState().tabs.find(tab => tab.appId === bridge.appId)?.key
if (!pageKey) throw new Error('尚未打开本应用页面')
const page = bridge.page(pageKey)
// 页级调用:宿主捕获此页快照,关闭/刷新/释放旧页后不会误绑定同 tabId 的新页。
const fromPage = await page.callHost('records.read', 'record-2')
// 页面卸载时释放,不会关闭宿主页签。
page.dispose()也可用 bridge.bindPage(tabId) 捕获调用时该页的当前生命周期。组件应持有属于自己的 PageSDK,不要每次点击都取“当前活动页”重新绑定,否则后台异步任务可能操作另一页。
- 页签关闭、刷新或同 ID 被新生命周期替换,旧页调用不再交付结果。
page.dispose()清理该 SDK 的订阅与调用上下文,不关闭页签。- 应用最后一页关闭会释放旧应用桥;重新打开后需要取得新桥,不要长期保存旧引用。
host.dispose()使宿主调用失效。应用桥没有供子应用直接调用的dispose()。
4. 超时、取消与副作用
callHost(name, ...args) 使用宿主默认超时;指定选项必须调用 callHostWithOptions(name, options, ...args),选项不是业务参数的最后一个对象。
ts
const controller = new AbortController()
const pending = page.callHostWithOptions(
'records.read',
{ timeoutMs: 8_000, signal: controller.signal },
'record-3',
)
// 在取消按钮/组件卸载逻辑中调用:
// controller.abort()
try {
const result = await pending
console.info(result.id)
} catch (error) {
if (error instanceof HostCallError) {
if (error.code === 'CANCELLED') {
// 用户取消,不必展示业务失败提示。
} else if (error.code === 'TIMEOUT') {
// 超时不代表服务端未完成,写操作重试前应核实结果。
} else {
console.warn('宿主调用失败', error.code)
}
} else {
throw error
}
}默认超时为 30_000ms;callTimeoutMs 和单次 timeoutMs 都要求正有限数且不超过 2_147_483_647。非法单次选项以 TypeError 拒绝,而不是业务错误码。已经中止的 signal 直接得到 CANCELLED,不执行处理器。
超时、主动取消、页面释放、桥释放或宿主销毁会中止 handler 的 context.signal 并拒绝等待中的调用。迟到结果不会再交付,迟到拒绝也被内部处理;不会强制终止用户 Promise、回滚已完成的写入、自动重试或保证业务“恰好一次”。handler 必须主动传递 signal,并在异步等待后、提交副作用前检查有效性。对服务端写入应另行设计幂等键与结果核实。
稳定错误码
HostCallError.code | 原因与处理建议 |
|---|---|
UNKNOWN_CAPABILITY | 宿主未注册名称,检查版本与注册表 |
FORBIDDEN | 当前应用不在该能力白名单 |
PAGE_RELEASED | 页级 SDK 或绑定生命周期已释放 |
BRIDGE_RELEASED | 应用桥已释放,需要新挂载取得新桥 |
HOST_DISPOSED | 宿主已销毁 |
HANDLER_FAILED | 处理器同步抛错或异步拒绝,宿主内部排查 |
TIMEOUT | 调用等待超时 |
CANCELLED | 调用者主动取消 |
处理器原始异常、cause、原始堆栈与敏感业务字段不会传给子应用。公开 HostCallError 自身仍是正常的 Error,具有自己的 stack。不要解析 message,也不要假定处理器主动抛出的自定义错误会原样透传。
5. 业务事件:类型扩展与可信来源
SDK 预定义 'record:saved': { appId: string; id: string }。业务可通过模块声明合并扩展 BusinessEvents;以下声明文件需要被双方 TypeScript 配置包含。
ts
// business-events.d.ts
import '@axewo/fin-micro/sdk'
declare module '@axewo/fin-micro/sdk' {
interface BusinessEvents {
'invoice:submitted': { invoiceId: string }
}
}ts
// 宿主:metadata 由宿主生成,不能从 payload.appId 推断来源。
const stopHostEvents = host.bus.on('invoice:submitted', (payload, metadata) => {
if (metadata.source !== 'app' || metadata.appId !== 'records') return
console.info('已登记来源', metadata.appId, metadata.tabKey, payload.invoiceId)
})
// 子应用:page 是已绑定生命周期的 PageSDK。
const stopPageEvents = page.on('record:saved', (payload, metadata) => {
console.info('刷新记录', payload.id, metadata.source)
})
page.emit('invoice:submitted', { invoiceId: 'invoice-1' })
// 宿主发出的事件标记为 { source: 'host' }。
host.bus.emit('record:saved', { appId: 'records', id: 'record-1' })
// 在所属组件或宿主模块退出时调用;取消函数幂等。
stopPageEvents()
stopHostEvents()事件第二参数 BusinessEventMetadata:
- 宿主事件:
source: 'host'。 - 页级事件:
source: 'app',包含宿主绑定的appId与tabKey。 - metadata 由宿主闭包生成并冻结;payload 不承担可信身份作用。负载内即使有同名字段,也不能覆盖 metadata。
广播是同步、进程内的;没有请求 ID、返回值、持久化、重放或跨窗口投递,也没有按业务事件名配置的应用权限白名单。需要限制结果读取与授权时使用显式宿主能力。页面订阅随 SDK/页面释放自动清理,也应在组件更早卸载时主动取消。宿主业务总线会将监听器异常交给 onError 隔离;低层 createNotificationBus<Events>() 是独立总线,原样传播同步监听异常,不要混淆二者。
6. 公开会话与按需读取凭据
公开会话只允许:
ts
interface SessionInput {
readonly authenticated: boolean
readonly user?: { readonly id: string; readonly name?: string }
readonly permissions: readonly string[]
}
// SessionSnapshot 在上述字段之外增加 readonly revision: number。宿主初始 revision 为 0,每次成功 updateSession 增加 1。未配置时为未认证、空权限。宿主按白名单字段复制并冻结快照、user 与 permissions;额外字段不会成为公开会话的扩展通道。
ts
// 子应用挂载时订阅;注册后立即同步收到当前快照。
const stopSession = bridge.subscribeSession(session => {
// 更新 UI 可见性及本地公开身份;不保存 Token,不把它作为服务端授权结果。
renderIdentity(session.authenticated, session.user?.name, session.permissions)
})
const initial = bridge.getSession()
// renderIdentity 是业务 UI 更新函数;组件卸载时:
stopSession()subscribeSession 的首次重放和后续更新异常均由宿主 onError 隔离,桥释放时自动取消;重复传入相同函数的订阅也具有独立取消生命周期。更新同步通知;若监听器重入更新会话,宿主不会继续向其余监听器回放已经过期的旧轮快照。
Token 不属于公开会话
SDK 公开 getToken(options?)、TokenCapabilities、TokenResult 与 createTokenCapability。getToken 通过既有 callHostWithOptions('getToken', ...) 获取最新凭据,不建立第二套 RPC。可显式传 bridge,否则读取当前 Wujie bridge;独立运行无桥抛 TokenError(NO_BRIDGE)。已登录 Cookie 模式返回 null,未登录抛 UNAUTHENTICATED,异步期间退出/切换抛 SESSION_CHANGED,非法结果抛 INVALID_RESULT;能力拒绝/取消/超时保留原有 HostCallError,均不回显凭据。
ts
// 每次请求按需读取,而非启动时永久缓存。
import { getToken } from '@axewo/fin-micro/sdk'
const controller = new AbortController()
const token = await getToken({
bridge,
timeoutMs: 5_000,
signal: controller.signal,
})
await fetch('/api/records', {
signal: controller.signal,
headers: token === null ? {} : { Authorization: `Bearer ${token}` },
})基座用 capabilities: { getToken: createTokenCapability({ apps: ['business'], getSession, provider }) } 显式注册。getSession 返回当前认证及单调递增 revision(登录、退出、用户切换必须递增),provider(context) 每次读取私有内存或用 context.signal 调后台。helper 在异步后复查 revision、认证和上下文存活,拒绝晚到旧凭据;同窗口桥不是针对恶意应用的安全沙箱。
CLI scaffold 的 host 模板接受 login/session 可选 accessToken,先以该次局部 Token 请求可信 /api/menus,再通过私有 WeakMap 候选值和认证 generation 校验后提交内存;默认仍为 HttpOnly Cookie,不生成假 Token。重新初始化、退出或卸载均清除凭据。系统退出确认期间保留身份与凭据,取消则继续使用;确认后在局部捕获旧 Token,立即撤销本地身份,再仅向可信 /api/logout 发送该值,不恢复旧身份。Token 不属于 PlatformSession 或 SessionSnapshot。纯 Bearer 内存模式跨浏览器刷新会丢失凭据,需要 Cookie 或业务提供的安全恢复接口;模板不会从 localStorage 恢复。
host 的应用注册默认 shareToken:false,需要宿主认证时必须为完全可信的应用显式开启。平台适配拆分为 auth/session/menu/access,platform.ts 保留旧导出;平台 login/logout 生产默认要求服务端 CSRF Token。数量上限、CSRF 接入示例与开发 CORS 白名单见平台适配与生产配置。
项目的 requestUtils.ts 通过 fin-core/http/authenticated 创建独立认证请求上下文。installSessionAuth(session) 统一安装宿主或独立凭据与原请求失效票据;卸载取消挂起读取并还原。只向显式可信目录自动注入 Bearer;绝对 URL、覆盖 baseURL、目录逃逸、控制字符与编码斜线不注入。跨域网关同时配置 HTTPS 可信目录和客户端 baseURL;SSR 没有 location 时须使用绝对 HTTPS 目录。服务端须避免把认证请求重定向到不可信目标;后注册拦截器和 adapter 属于可信应用代码,不能在注入后篡改请求目标。
仅注入当前应用的单个 bridge,不把 host 或 host.bridge 工厂交给子应用。getToken 仅授予完全可信应用;同窗口白名单不抵御恶意应用代码或 XSS。Token 也不能藏入公开 user、permissions、页签参数等旁路字段。若业务另行接入 iframe,须显式 allowedNames: ['getToken']、精确 remoteOrigin 并绑定单个已授权 bridge;origin 无法区分同源不同路径,本轮不增加 iframe Token 封装。
实际业务仅将凭据发送到明确受信任的业务 API,不输出日志、不写 URL、不持久化、不广播,也不自动转发给嵌套 iframe;更小的信任面可以改为只开放宿主代发请求的业务能力。每个请求按需读取可避免启动时的旧 Token,但不提供自动刷新、失败自动重放或服务端权限保证。
退出与清理顺序
生成模板先用 FDialog 显示一次系统退出确认,取消则保留会话和表单,确认后再执行以下清理,不触发逐页未保存确认。真实认证失效直接执行清理。SDK 不内置登出流程:
- 清除宿主私有凭据,避免后续按需读取继续返回旧 Token。
host.updateSession({ authenticated: false, permissions: [] })发布无用户的公开快照。- 取消业务自己持有的请求控制器,清空用户相关业务缓存;已返回给可信应用的凭据无法由 SDK 收回。
- dispose 工作区,卸载业务页面并清理订阅;普通关闭/刷新仍遵循导航事务,不能假定会话更新自动关闭页面。
ts
function clearAuthentication() {
currentAccessToken = null
host.updateSession({ authenticated: false, permissions: [] })
// 再由业务模块取消请求、清理数据和执行导航;SDK 不自动完成这些动作。
}单纯 updateSession 不自动取消所有能力调用、不吊销能力白名单、不刷新 UI 以外的权限状态,也不强制清理已缓存数据。敏感能力应在执行时校验当前认证状态,服务端仍须检查会话与权限。
7. 协议协商
ts
const protocol = bridge.protocol()
// 当前 version: '1.0'
// features: ['call-options', 'session', 'event-metadata', 'navigate-to', 'back-target']
// 宿主开启 crossAppBack 时还包括 'cross-app-back'。
// capabilities: 仅包含已注册且对当前应用授权的名称。
const agreement = bridge.negotiate({
version: '1.0',
capabilities: ['records.read'],
})
if (!agreement.compatible) {
console.warn('宿主协议不兼容', agreement.missingCapabilities)
// 禁用相关操作或展示升级提示;不要继续假定能力存在。
}negotiate(requirement?) 返回 { compatible, protocol, missingCapabilities }。版本为精确字符串匹配,不是 semver 范围;不传版本则不检查版本,不传必需能力则不检查缺失。不兼容通常作为结果返回,而不是抛异常;桥/宿主释放仍会抛错。协商不调用处理器、不自动降级、不赋予权限,也不接受 features 作为检查参数。它与 iframe 消息信封里的数字 version: 1 是不同协议层。
集成检查
- 使用具体能力契约,处理器自行校验外部参数;白名单与服务端鉴权各司其职。
- 页面操作使用绑定 key 的 SDK,应用请求层使用应用桥;释放后不复用旧引用。
- 超时和取消只进行协作中止,业务写入采用自己的幂等和回滚策略。
- 事件来源读取第二参数,公开会话不放凭据,退出显式清理业务请求和缓存。
- 独立运行时明确选择独立实现或禁用宿主能力,不伪造调用成功。