Skip to content

iframe:显式跨窗口 RPC ​

@axewo/fin-micro/iframe 提供独立、双向的 postMessage 请求响应端点,不依赖 Vue,也不会读取全局 window。它适合给明确受信任的父子窗口暴露少量业务方法。

它不是 AppBridge 的透明远程代理,不是页签路由、导航队列或页面缓存代理。 ready、状态订阅、会话订阅、事件总线、PageSDK、函数守卫和 keep-alive 不会自动跨窗口工作。如果只是 Wujie 注入的同窗口业务桥,优先阅读 宿主通信,不必额外套一层 RPC。

1. API 与边界 ​

ts
import {
  createIframeRpc,
  exposeHostCapabilities,
  IframeRpcError,
} from '@axewo/fin-micro/iframe'
import type {
  IframeRpc,
  IframeRpcOptions,
  IframeRequestOptions,
  IframeHandlerContext,
  IframeRpcHandler,
  IframeRpcErrorCode,
} from '@axewo/fin-micro/iframe'

端点接口:

ts
interface IframeRpc {
  request<T = unknown>(
    method: string,
    args: unknown[],
    options?: { timeoutMs?: number; signal?: AbortSignal },
  ): Promise<T>
  expose(
    method: string,
    handler: (args: unknown[], context: { readonly signal: AbortSignal }) =>
      unknown | Promise<unknown>,
  ): () => void
  dispose(): void
}
  • request 的第二参数是参数数组,不是展开参数。返回值泛型只作本地静态断言,不校验远端结果。
  • expose 每个方法只能有一个 handler;重复注册、名称无效或 handler 非函数同步抛 TypeError。返回幂等注销函数,只移除该注册,不取消已执行的 handler。
  • 双方都可 expose 和 request,不限定父调用子。
  • 方法名、channel、sessionId 都要求字符串长度在 1~128;不要以为模块会校验业务命名规则。
  • dispose() 幂等,释放本端监听、计时器、待完成请求和入站 signal,不负责卸载 iframe 或关闭窗口。

2. 必须显式约定连接参数 ​

配置父端示例子端示例
localWindow父页面的 windowiframe 内的 window
remoteWindow对应 iframe.contentWindowwindow.parent
remoteOrigin子页面精确 origin父页面精确 origin
channel双方相同的业务通道字符串与父端相同
sessionId每次连接新建并显式交给子端使用父端本次传入的值

接收消息时同时检查 event.source、event.origin、协议标识、版本、channel、sessionId 和消息结构。不同条件不能相互替代。

remoteOrigin 必须严格等于 URL 解析后的 HTTP(S) origin,例如 http://localhost:5174。以下均不接受:*、null、https://example.com/、带路径/query/hash 的 URL、非 HTTP(S) 协议,以及与规范化 origin 不相等的文本。即使 / 看起来无害,也不是精确 origin 字符串。

模块没有自动发现、握手、重连或 session 生成。双方必须在发送业务请求前安装端点和方法。不要复用同一窗口对、同一 channel/session 创建多个端点,否则同一消息可能被多次处理。新挂载或重连应生成新 session,并释放旧端点。

3. 可复制的双端示例 ​

假设父页面运行在 http://localhost:5173,子页面运行在 http://localhost:5174。子页面 /iframe.html 的入口加载下面的 child.ts。实际部署时同时替换两个固定 origin,不要从任意消息中学习信任对象。

这个例子用 URL query 传递非秘密的连接 sessionId,channel 与允许的父源均固定在代码中。sessionId 不是认证密钥,不能用来替代登录态或服务端授权。不要把 Token 或其他凭据加入这个 URL。

父页面 parent.ts ​

ts
import { createIframeRpc, IframeRpcError } from '@axewo/fin-micro/iframe'

const CHILD_ORIGIN = 'http://localhost:5174'
const CHANNEL = 'records-demo'

export function mountChild(container: HTMLElement): () => void {
  const iframe = document.createElement('iframe')
  iframe.title = '记录子页面'
  // 先挂载空 iframe,以取得真实的 WindowProxy,再为目标页面配置 src。
  container.append(iframe)
  const remoteWindow = iframe.contentWindow
  if (!remoteWindow) {
    iframe.remove()
    throw new Error('无法取得 iframe 窗口')
  }

  const sessionId = crypto.randomUUID()
  const rpc = createIframeRpc({
    localWindow: window,
    remoteWindow,
    remoteOrigin: CHILD_ORIGIN,
    channel: CHANNEL,
    sessionId,
    timeoutMs: 10_000,
    handlerTimeoutMs: 10_000,
    maxPending: 20,
  })
  const controller = new AbortController()
  let disposed = false
  let ready = false

  // ready 是本例自行定义的业务方法,不是库自带握手。
  const unexposeReady = rpc.expose('demo.ready', args => {
    if (args.length !== 0) throw new Error('ready 不接受参数')
    if (!ready) {
      ready = true
      // 子端在请求 ready 前已经注册 demo.echo,因此此时可反向请求。
      void rpc.request<{ text: string }>(
        'demo.echo',
        ['来自父页面的消息'],
        { timeoutMs: 5_000, signal: controller.signal },
      ).then(result => {
        if (typeof result?.text !== 'string') throw new Error('echo 返回值无效')
        if (!disposed) console.info('子端回应', result.text)
      }).catch(reportError)
    }
    return { accepted: true }
  })

  const unexposeRead = rpc.expose('records.read', async (args, { signal }) => {
    const [id] = args
    if (args.length !== 1 || typeof id !== 'string' || !id.trim()) {
      throw new Error('记录参数无效')
    }
    // 此处的相对 URL 由父页面解释,必须由服务端继续校验身份和权限。
    const response = await fetch(`/api/records/${encodeURIComponent(id)}`, { signal })
    if (!response.ok) throw new Error('读取记录失败')
    const result: unknown = await response.json()
    if (!result || typeof result !== 'object' ||
        !('id' in result) || typeof result.id !== 'string' ||
        !('title' in result) || typeof result.title !== 'string') {
      throw new Error('接口结果无效')
    }
    if (signal.aborted) throw new Error('调用已经取消')
    return { id: result.id, title: result.title }
  })

  // 监听器及父端方法先安装,再加载子页面;仅设置 onload 不能保证子端方法已就绪。
  const url = new URL('/iframe.html', CHILD_ORIGIN)
  url.searchParams.set('rpcSession', sessionId)
  iframe.src = url.href

  function reportError(error: unknown) {
    if (disposed) return
    if (error instanceof IframeRpcError) {
      console.warn('iframe 请求失败', error.code)
    } else {
      console.warn('业务结果校验失败')
    }
  }

  // 组件卸载时调用;本例不自动处理 iframe 自刷新后的重连。
  return () => {
    if (disposed) return
    disposed = true
    controller.abort()
    unexposeReady()
    unexposeRead()
    rpc.dispose()
    iframe.remove()
  }
}

const container = document.querySelector<HTMLElement>('#child-container')
if (!container) throw new Error('请在父页面添加 <div id="child-container"></div>')
const unmount = mountChild(container)
window.addEventListener('pagehide', unmount, { once: true })
// SPA 组件更早卸载时也需调用 unmount()。

子页面 child.ts ​

ts
import { createIframeRpc, IframeRpcError } from '@axewo/fin-micro/iframe'

const PARENT_ORIGIN = 'http://localhost:5173'
const CHANNEL = 'records-demo'
if (window.parent === window) throw new Error('此页面必须在可信父页面 iframe 内运行')

const sessionId = new URL(window.location.href).searchParams.get('rpcSession')
if (!sessionId || sessionId.length > 128) throw new Error('缺少有效的本次连接标识')

const rpc = createIframeRpc({
  localWindow: window,
  remoteWindow: window.parent,
  remoteOrigin: PARENT_ORIGIN,
  channel: CHANNEL,
  sessionId,
  timeoutMs: 10_000,
  handlerTimeoutMs: 10_000,
  maxPending: 20,
})
const controller = new AbortController()
let disposed = false

// 先注册子端方法,再通知父端业务就绪。
const unexposeEcho = rpc.expose('demo.echo', (args, { signal }) => {
  if (signal.aborted) throw new Error('调用已取消')
  if (args.length !== 1 || typeof args[0] !== 'string') throw new Error('消息参数无效')
  return { text: `子页面收到:${args[0]}` }
})

async function start() {
  const agreement = await rpc.request<{ accepted: boolean }>(
    'demo.ready', [], { signal: controller.signal },
  )
  if (agreement?.accepted !== true) throw new Error('父页面未确认就绪')

  const record = await rpc.request<{ id: string; title: string }>(
    'records.read',
    ['record-1'],
    { timeoutMs: 8_000, signal: controller.signal },
  )
  // 泛型不是运行时校验,接收方仍校验结果。
  if (typeof record?.id !== 'string' || typeof record?.title !== 'string') {
    throw new Error('记录返回值无效')
  }
  if (!disposed) document.body.textContent = record.title
}

void start().catch(error => {
  if (disposed) return
  if (error instanceof IframeRpcError) {
    document.body.textContent = `请求未完成:${error.code}`
  } else {
    document.body.textContent = '连接或业务数据无效'
  }
})

function cleanup() {
  if (disposed) return
  disposed = true
  controller.abort()
  unexposeEcho()
  rpc.dispose()
}
window.addEventListener('pagehide', cleanup, { once: true })
// 若 iframe 内也有 SPA 组件生命周期,应在所属组件卸载时调用 cleanup()。

这些代码展示了真实窗口引用、双向暴露、就绪约定、单次取消与销毁。示例的就绪请求也受超时限制,但父端没有自动重连计时器。iframe 导航、刷新、源改变、加载失败时,应用应销毁旧连接并重新挂载、共享新的 session,而不是自动放宽 origin 检查或重试有副作用的请求。若浏览器恢复了已清理连接的页面,也需显式重建。

4. 只开放指定宿主能力 ​

若父页面已经持有 AppBridge,可以通过 exposeHostCapabilities 将其中一小部分能力绑定到 RPC。bridge 必须由父页面可信闭包选定,不允许读取子端参数里的 appId 再任意获取桥。

ts
import { createMicroHost } from '@axewo/fin-micro/core'
import { exposeHostCapabilities } from '@axewo/fin-micro/iframe'
import type { IframeRpc } from '@axewo/fin-micro/iframe'

interface Capabilities {
  'records.read': (id: string) => Promise<{ id: string; title: string }>
}

const host = createMicroHost<Capabilities>([
  { appId: 'records', name: '记录管理', entry: 'http://localhost:5174' },
], {
  capabilities: {
    'records.read': {
      apps: ['records'],
      async handler(context, id) {
        if (typeof id !== 'string' || !id.trim()) throw new Error('参数无效')
        if (!context.isActive()) throw new Error('调用无效')
        // 替换成真实业务逻辑,异步请求应使用 context.signal。
        return { id, title: '示例记录' }
      },
    },
  },
})

// rpc 是按上一节精确窗口/origin/channel/session 建立的父端端点。
function attachCapabilities(rpc: IframeRpc) {
  const trustedBridge = host.bridge('records')
  const unexpose = exposeHostCapabilities(rpc, trustedBridge, ['records.read'])
  return () => {
    unexpose()
    rpc.dispose()
    // 若 host 由更高层共享,不应在这个局部清理函数中销毁 host。
  }
}

子端调用仍然是普通 RPC:

ts
const record = await rpc.request<{ id: string; title: string }>(
  'records.read', ['record-1'], { timeoutMs: 5_000 },
)

桥接适配器只完成以下动作:

  1. 去重 allowedNames,逐一 rpc.expose(name, handler)。
  2. 将入站 args 展开为业务参数,并将 RPC handler 的 signal 传给 bridge.callHostWithOptions(name, { signal }, ...args)。
  3. 返回注销这些方法的函数;注册途中失败会回滚本次已注册的方法。

它不校验所选名字是否已由宿主注册、不绕过应用白名单、不自动获取页面 SDK。宿主能力白名单仍然生效,宿主自身的默认调用超时也仍生效;两个层级各自有期限,先结束的层级决定调用表现。注销方法不取消已有调用;销毁端点才能中止其入站 signal。

注意错误边界:宿主调用拒绝(例如 HostCallError('FORBIDDEN', ...))在 RPC handler 看来是普通异常,因此子端通常收到 IframeRpcError 的 HANDLER_FAILED,不是远程透传的 HostCallError。若业务需要返回可公开的领域失败原因,应设计并校验普通数据结果(例如判别联合),不要依赖异常对象、stack 或 cause 跨窗口传播。

不要把 bridge 整体作为参数或结果发送,也不要默认把 auth.getAccessToken 暴露给 iframe。即使父窗口中的某个子应用可读 Token,也不等于嵌套窗口自动获得同等信任。

5. 超时、容量与取消 ​

项目默认值约束与行为
timeoutMs30_000ms端点出站请求默认等待期限
单次 request(..., { timeoutMs })端点 timeoutMs只覆盖本次请求方等待期限
handlerTimeoutMs端点 timeoutMs本端入站 handler 的独立最长执行期限
maxPending100正安全整数,分别限制出站 pending 数量和入站执行数量

期限要求正有限数且不超过 2_147_483_647。非法端点配置同步抛 TypeError,非法单次请求参数/期限以 TypeError 拒绝。

  • 出站超限直接拒绝 LIMIT_EXCEEDED,不排队、不自动重试。序号达到 Number.MAX_SAFE_INTEGER 也拒绝该错误,需建立新连接生命周期。
  • 入站超限向请求方回应 LIMIT_EXCEEDED。出站和入站分别计算,不是两者数量相加共用一个槽池。
  • 请求方超时或主动取消,会先结束本地等待,再尽力发送取消消息。对端收到后中止 handler signal 并释放入站槽位。
  • handler 到期时中止 signal、释放槽位并尽力返回 TIMEOUT;忽略 signal 的业务 Promise 仍可能继续运行,其迟到结果不再发送。
  • 单次请求 timeout 不会通过消息重新配置对端 handlerTimeoutMs。 对端期限独立;正常情况下取消消息可提前中止对端,但不能承诺通知一定到达。
  • 没有强制停止 JS、撤销网络写入、回滚、自动重试、离线队列或持久化投递保证。对有副作用的请求使用业务自己的幂等机制。

容量限制只限制同时在途数量;实现没有额外的消息字节数、业务 payload 大小、每秒请求量或 handler 总计算量限制。暴露端应自行控制负载长度与计算量,不要把 maxPending 当作完整限流或资源安全方案。

6. 错误处理 ​

ts
try {
  await rpc.request('records.read', ['record-1'])
} catch (error) {
  if (error instanceof IframeRpcError) {
    switch (error.code) {
      case 'CANCELLED':
      case 'DISPOSED':
        break
      default:
        console.warn('RPC 未完成', error.code)
    }
  } else {
    // 例如本地请求参数无效时的 TypeError。
    throw error
  }
}
IframeRpcError.code含义
TIMEOUT请求方等待到期,或对端 handler 到期
CANCELLED本次请求 signal 已取消
DISPOSED本端已释放,或释放时结束本端正在等待的请求
LIMIT_EXCEEDED本端/对端在途上限,或本端请求序号耗尽
UNKNOWN_METHOD对端没有暴露所请求的方法
HANDLER_FAILED对端 handler 同步抛错或异步拒绝
SERIALIZATION_FAILED请求/结果无法发送或结构化克隆失败

公开错误只有固定 code 与固定对外消息,不透传远端业务异常文本、cause 或原始堆栈。不要用解析 message 的方式分流。创建配置或 expose 的使用错误不是远端业务失败,应在开发阶段修正。

7. 结构化克隆与消息限制 ​

通信使用浏览器 postMessage,不是 JSON 编码,也不是共享对象引用:

  • 参数和返回值必须可被结构化克隆。普通数据对象、数组、字符串、布尔、数值等适合作为业务 DTO;其他浏览器可克隆类型应结合目标环境验证。
  • 函数、DOM 节点、Window、包含函数的服务对象、PageSDK / AppBridge 等不能作为协议数据发送。不要试图发送 AbortSignal 来实现取消,取消是独立的协议消息。
  • 不提供 transfer list;不要假定 ArrayBuffer 会以所有权转移方式发送,也不要依赖自定义类实例的原型、方法和引用身份。
  • request<T> 不做 schema 校验。方法内校验参数,收到结果后校验结果;对资源大小和可公开字段另行约束。
  • 请求发送失败会本地拒绝 SERIALIZATION_FAILED;结果发送失败时对端尝试回应该错误。错误/取消等兜底消息也可能无法送达,最终依靠请求方超时,不能保证总能拿到错误回包。

内部消息标识为 protocol: 'fin-micro-rpc'、数字 version: 1。这不是应用桥的 '1.0' 协商接口。实现不公开自动版本协商或兼容降级机制,不应手工构造信封冒充正常 request。

8. 销毁、重放与安全检查 ​

dispose() 会:

  1. 移除本端 message 监听。
  2. 将本端所有未完成请求以 DISPOSED 拒绝,并尽力通知对端取消。
  3. 中止本端所有入站 handler 的 signal,清理期限及方法注册。

它没有“连接已关闭”的断连握手。对端正在等待本端响应的请求不会自动立刻得到 DISPOSED,仍由对端自己的取消或 timeout 兜底。释放后 request 拒绝 DISPOSED,expose 同步抛 DISPOSED;不能复活旧端点。

每个 session 的出站 requestId 是单调递增的正安全整数十进制字符串。接收方只保存入站高水位,忽略重复和旧序号,防重放状态保持常量空间。这不等于跨重连业务去重,更不等于服务端恰好一次提交;新 session 的重试仍需业务幂等。

上线前逐项确认:

  • remoteWindow 是真正目标窗口,不是任意窗口传入的引用;origin 固定且精确。
  • 父端在加载目标页面前建立监听并暴露方法,子端在声明就绪前暴露自身方法。
  • 同一窗口对/channel/session 只有一对端点,重连生成新 session,旧连接明确 dispose。
  • origin 是源级边界,不区分同源下的可信/不可信路径;同源页面泄露或 XSS 不会被这个 RPC 自动隔离。
  • sessionId 只是生命周期绑定值,不是秘密凭据;不从未验证的消息接受 origin、appId 或授权名单。
  • 如配置 sandbox 导致子页面成为不透明 origin(消息 origin 为 null),本模块的精确 HTTP(S) origin 约束不适用;不要用 * 绕过。应重新设计加载与信任模型,不能为了通信随意削弱隔离。
  • 只暴露最小方法集;服务端鉴权、参数校验、资源限制、业务幂等和数据脱敏由业务实现。