159 lines
5.8 KiB
TypeScript
159 lines
5.8 KiB
TypeScript
/**
|
||
* 插件页(iframe)侧桥接 client。
|
||
* 用法(插件页入口):`import { getHarnessClient } from '@aso/plugin-sdk/client'`
|
||
* 通信:postMessage —— 页 → window.parent;宿主 → iframe.contentWindow。
|
||
* 宿主严格校验消息来源 origin(plugin://<id>);invoke 仅暴露白名单方法。
|
||
*/
|
||
import type { OpenViewAction, StatusItem } from './status'
|
||
|
||
/** 桥协议版本(宿主与插件页握手比对;不匹配时宿主拒绝 invoke) */
|
||
export const HARNESS_BRIDGE_VERSION = 1
|
||
|
||
/** 宿主 → 插件页 */
|
||
export type HostToPluginMessage =
|
||
| { v: number; type: 'harness:hello'; contract: string }
|
||
| { v: number; type: 'harness:theme'; dark: boolean; accent?: string }
|
||
| { v: number; type: 'harness:deep-link'; params?: unknown }
|
||
| { v: number; type: 'harness:agent'; event: HarnessAgentEvent }
|
||
| { v: number; type: 'harness:invoke-result'; seq: number; ok: true; value: unknown }
|
||
| { v: number; type: 'harness:invoke-result'; seq: number; ok: false; error: string }
|
||
|
||
/** 插件页 → 宿主 */
|
||
export type PluginToHostMessage =
|
||
| { v: number; type: 'plugin:hello'; pluginId: string }
|
||
| { v: number; type: 'plugin:open-view'; view: string; params?: unknown }
|
||
| { v: number; type: 'plugin:set-status'; id: string; item: StatusItem | null }
|
||
| { v: number; type: 'plugin:invoke'; seq: number; method: string; args: unknown[] }
|
||
|
||
/** 宿主中继的 agent 事件(宽松类型:具体事件形状由宿主版本决定,插件页防御性读取) */
|
||
export interface HarnessAgentEvent {
|
||
type: string
|
||
[key: string]: unknown
|
||
}
|
||
|
||
/** invoke 白名单(宿主桥逐方法放行;后续按需扩充) */
|
||
export interface HarnessInvokeMap {
|
||
getState(): unknown
|
||
send(text: string, images?: { mimeType: string; data: string }[]): void
|
||
getSettings(): unknown
|
||
}
|
||
|
||
export interface HarnessTheme {
|
||
dark: boolean
|
||
accent?: string
|
||
}
|
||
|
||
export interface HarnessClient {
|
||
/** 握手完成(收到 harness:hello)后 resolve */
|
||
ready: Promise<void>
|
||
/** 宿主契约版本(ready 后可用) */
|
||
contractVersion(): string
|
||
/** 深链打开视图 */
|
||
openView(view: string, params?: unknown): void
|
||
/** 写/清一条状态胶囊(item=null 移除) */
|
||
setStatus(id: string, item: StatusItem | null): void
|
||
/** 深链参数:页面被 openView 打开/更新时回调(含挂起期间的一次重放) */
|
||
onDeepLink(cb: (params: unknown) => void): () => void
|
||
/** 主题变化(深浅色/accent) */
|
||
onTheme(cb: (theme: HarnessTheme) => void): () => void
|
||
/** agent 事件中继(宿主挑选的子集:state/agent_start/agent_end/stats…) */
|
||
onAgentEvent(cb: (event: HarnessAgentEvent) => void): () => void
|
||
/** 调用宿主白名单 API */
|
||
invoke<M extends keyof HarnessInvokeMap>(
|
||
method: M,
|
||
...args: Parameters<HarnessInvokeMap[M]>
|
||
): Promise<Awaited<ReturnType<HarnessInvokeMap[M]>>>
|
||
}
|
||
|
||
type DeepLinkCb = (params: unknown) => void
|
||
type ThemeCb = (theme: HarnessTheme) => void
|
||
type AgentCb = (event: HarnessAgentEvent) => void
|
||
|
||
let cached: HarnessClient | null = null
|
||
|
||
/** 获取桥接 client(单例;首个调用即开始握手) */
|
||
export function getHarnessClient(pluginId: string): HarnessClient {
|
||
if (cached) return cached
|
||
|
||
let contract = ''
|
||
let resolveReady: (() => void) | null = null
|
||
const ready = new Promise<void>((resolve) => {
|
||
resolveReady = resolve
|
||
})
|
||
|
||
const deepLinkCbs = new Set<DeepLinkCb>()
|
||
const themeCbs = new Set<ThemeCb>()
|
||
const agentCbs = new Set<AgentCb>()
|
||
const pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void }>()
|
||
let seq = 0
|
||
|
||
window.addEventListener('message', (event: MessageEvent) => {
|
||
const msg = event.data as HostToPluginMessage | undefined
|
||
if (!msg || typeof msg !== 'object' || typeof msg.type !== 'string' || !msg.type.startsWith('harness:')) return
|
||
switch (msg.type) {
|
||
case 'harness:hello':
|
||
contract = msg.contract
|
||
resolveReady?.()
|
||
resolveReady = null
|
||
break
|
||
case 'harness:theme':
|
||
for (const cb of themeCbs) cb({ dark: msg.dark, accent: msg.accent })
|
||
break
|
||
case 'harness:deep-link':
|
||
for (const cb of deepLinkCbs) cb(msg.params)
|
||
break
|
||
case 'harness:agent':
|
||
for (const cb of agentCbs) cb(msg.event)
|
||
break
|
||
case 'harness:invoke-result': {
|
||
const waiter = pending.get(msg.seq)
|
||
if (!waiter) break
|
||
pending.delete(msg.seq)
|
||
if (msg.ok) waiter.resolve(msg.value)
|
||
else waiter.reject(new Error(msg.error))
|
||
break
|
||
}
|
||
default:
|
||
break
|
||
}
|
||
})
|
||
|
||
const post = (msg: PluginToHostMessage): void => {
|
||
// 宿主 origin 随运行模式不同(file:// 或 dev server),用 '*' 发送;
|
||
// 方向安全由宿主侧的 origin 白名单校验保证
|
||
window.parent?.postMessage({ ...msg, v: HARNESS_BRIDGE_VERSION }, '*')
|
||
}
|
||
|
||
const invokeRaw = (method: string, ...args: unknown[]): Promise<unknown> =>
|
||
new Promise((resolve, reject) => {
|
||
const id = ++seq
|
||
pending.set(id, { resolve, reject })
|
||
post({ v: HARNESS_BRIDGE_VERSION, type: 'plugin:invoke', seq: id, method, args })
|
||
})
|
||
|
||
post({ v: HARNESS_BRIDGE_VERSION, type: 'plugin:hello', pluginId })
|
||
|
||
cached = {
|
||
ready,
|
||
contractVersion: () => contract,
|
||
openView: (view, params) => post({ v: HARNESS_BRIDGE_VERSION, type: 'plugin:open-view', view, params }),
|
||
setStatus: (id, item) => post({ v: HARNESS_BRIDGE_VERSION, type: 'plugin:set-status', id, item }),
|
||
onDeepLink(cb) {
|
||
deepLinkCbs.add(cb)
|
||
return () => void deepLinkCbs.delete(cb)
|
||
},
|
||
onTheme(cb) {
|
||
themeCbs.add(cb)
|
||
return () => void themeCbs.delete(cb)
|
||
},
|
||
onAgentEvent(cb) {
|
||
agentCbs.add(cb)
|
||
return () => void agentCbs.delete(cb)
|
||
},
|
||
invoke: invokeRaw as HarnessClient['invoke']
|
||
}
|
||
return cached
|
||
}
|
||
|
||
export type { OpenViewAction, StatusItem }
|