Files
inkreach-agent/packages/plugin-sdk/src/client.ts
T

159 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 插件页(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 }