@playcraft/iframe-bridge
v0.0.6
Published
PlayCraft iframe postMessage bridge SDK for parent ↔ child communication
Readme
@playcraft/iframe-bridge
PlayCraft iframe 通讯协议 SDK — 基于 window.postMessage,适用于 平台 (Parent) ↔ iframe 内游戏/编辑器 (Child) 场景。
发送方 bridge.emit(name, data);接收方 bridge.on(name, fn)。需要返回结果时 callback 返回数据(SDK 自动发 res);仅通知、无需回包时 callback 处理完即可。是否等待 res 由 wire name 决定,API 形状相同。
版本说明
每条 envelope 的 __playcraft__ 字段等于发送方 npm 包 version。接收端不拒绝版本不一致的消息,只要 envelope 结构合法就会处理;版本不同时 SDK 会在控制台按对端版本各打印一次 soft warn,例如:
[playcraft] protocol version mismatch: local 0.0.3, peer 0.0.2. Messages are still processed; align @playcraft/iframe-bridge versions when possible.建议:Parent 与 Child 尽量使用相同版本,避免 wire 协议或 payload 语义漂移(如 image.aiGenerate 返回格式变更)。出现功能异常时,先对比两侧 PROTOCOL_VERSION / package.json 并升级较低一侧。
安装
# pnpm
pnpm add @playcraft/iframe-bridge
# npm
npm install @playcraft/iframe-bridge
# yarn
yarn add @playcraft/iframe-bridgeChild 侧用法
Child 运行在 iframe 内,通过 bridge.emit 与 Parent 通讯。
1. 初始化 Bridge
推荐使用 setupChildBridge,它会创建 Bridge、注册 host.init 监听,并在嵌入模式下自动发送 child.ready:
import { setupChildBridge, BridgeMethods } from '@playcraft/iframe-bridge';
// Parent 打开 iframe 时应在 URL 带上 ?parentOrigin=<parent-origin>
const parentOrigin = new URLSearchParams(location.search).get('parentOrigin') ?? location.origin;
const { bridge, whenParentReady } = setupChildBridge({
parentOrigin,
// readyEvent: BridgeMethods.host.init, // 默认 — Parent 发来的就绪信号
// announceEvent: BridgeMethods.child.ready, // 默认 — Child 发出的 bridge 就绪通知
debug: true, // 或 (direction, envelope) => { ... } 记录 Timeline
});
// 等待 Parent 发送 host.init 后再调用其他 wire name
const init = await whenParentReady();
// init.params — Parent 可选传入的初始化参数若不用 setupChildBridge,需自行 new Bridge({ peer: () => window.parent, ... }) 并手动处理握手。
2. 调用(emit)
// 批量读路径(req 仅为 path 字符串数组,不含文件内容)
const { paths } = await bridge.emit(BridgeMethods.form.get, ['scene.bg', 'scene.role']);
// paths[i].data — string
// 批量写路径
await bridge.emit(BridgeMethods.form.set, {
paths: [{ path: 'scene.role', data: '...' }],
});
// AI 生图 — res 的 data 为 data URL,可直接用于 <img src>
const imageUrl = await bridge.emit(BridgeMethods.image.aiGenerate, {
prompt: '赛博朋克风格的城市夜景',
referenceImageBase64: ['data:image/png;base64,<base64>'],
});
// <img src={imageUrl} alt="" />
// 通知 Parent 关闭 iframe(无 res,emit 立即返回)
await bridge.emit(BridgeMethods.dialog.close, { reason: 'user_done' });3. 批量调用与超时
bridge.emit 的数组形式用于编排多个独立调用(可混合不同 wire name、各自 payload)。
读多个 path 请用上文单次 form.get + path 数组(1 次往返);不要拆成多次 form.get。
// 串行批量(默认)— 混合不同 wire name,按顺序执行
const [getRes, setRes, aiRes] = await bridge.emit([
[BridgeMethods.form.get, ['scene.bg', 'scene.role']],
[BridgeMethods.form.set, { paths: [{ path: 'scene.role', data: '...' }] }],
[
BridgeMethods.image.aiGenerate,
{ prompt: '赛博朋克城市', referenceImageBase64: ['data:image/png;base64,<base64>'] },
{ timeout: 60_000 }, // 可选:该 call 单独超时
],
]);
// 并行批量 — 互不依赖的 call 可同时发出
const parallel = await bridge.emit(
[
[BridgeMethods.form.get, ['scene.bg']],
[BridgeMethods.form.get, ['scene.ui']],
],
{ mode: 'parallel' },
);
// parallel[0] 成功时为 { paths: [...] };失败项为 { error: PlaycraftBridgeError }
// 单 call 超时 / 取消
const controller = new AbortController();
await bridge.emit(BridgeMethods.image.aiGenerate, payload, {
timeout: 60_000,
signal: controller.signal,
});4. 生命周期
// 页面卸载时销毁 Bridge,移除 message 监听
bridge.destroy();Child 侧典型时序
Child Parent
| child.ready (auto) --> |
| <-- | host.init
| form.get / set / ... --> | on → res
| dialog.close --> | on(无 res)→ 关闭 iframeParent 侧用法
Parent 挂载 iframe、注册 bridge.on、响应握手与 dialog.close。DOM 由业务方管理;Bridge 与握手推荐用 setupParentBridge。
1. 初始化 Bridge
import { setupParentBridge, buildChildIframeUrl, BridgeMethods } from '@playcraft/iframe-bridge';
const iframe = document.createElement('iframe');
iframe.src = buildChildIframeUrl(childUrl, { lang: 'zh' }).toString();
container.appendChild(iframe);
const { bridge, whenChildReady } = setupParentBridge({
iframe,
childOrigin: new URL(childUrl).origin,
init: { params: { theme: 'dark' } }, // 收到 child.ready 后自动 emit host.init
autoDetectChildReady: true, // iframe load / document complete 也视为 ready
debug: true,
});
await whenChildReady();buildChildIframeUrl 负责构建 Child iframe 的 src URL:固定追加 parentOrigin,其余键值对作为 query param(如 lang、debug);iframe 样式、挂载时机仍由业务控制。
若不用 setupParentBridge,需自行 new Bridge({ peer: () => iframe.contentWindow, ... }) 并手动处理握手。
2. 注册 on
bridge.on(BridgeMethods.form.get, async (pathKeys) => ({
paths: await Promise.all(
pathKeys.map(async (path) => ({ path, data: await loadPathData(path) })),
),
}));
bridge.on(BridgeMethods.form.set, async ({ paths }) => {
for (const entry of paths) {
await savePathData(entry.path, entry.data);
}
return { paths: paths.map(({ path }) => path) };
});
// 无 res — callback 处理完即可,不 return 结果
bridge.on(BridgeMethods.dialog.close, () => {
iframe.remove();
bridge.destroy();
});完整字段与错误约定见下文 API 参考。
3. 生命周期
bridge.destroy();Parent 侧典型时序
Child Parent
| child.ready (auto) --> | whenChildReady() resolve
| <-- | host.init (auto emit)
| form.get / set / ... --> | on → res
| dialog.close --> | on(无 res)→ 关闭 iframeParent 侧职责小结
| 职责 | API |
| ---------------- | -------------------------------------- |
| iframe DOM / URL | 业务方(可用 buildChildIframeUrl) |
| 握手 | setupParentBridge + whenChildReady |
| 业务逻辑 | bridge.on(有/无 res 均可) |
| 销毁 | bridge.destroy() |
调试
URL 带 ?debug 或 ?debug=1 时,setupChildBridge / setupParentBridge 会开启 envelope 日志。默认输出包含 Parent / Child 标识、发送方向(Child → Parent / Parent → Child),并用不同颜色区分两端(Parent 靛蓝、Child 琥珀色):
[Parent] Child → Parent · recv · req · child.ready
[Child] Parent → Child · recv · req · host.init也可传入自定义 DebugLogger 接管日志(如 Playground 的 Bridge Timeline):
import { isDebugFromUrl } from '@playcraft/iframe-bridge';
setupChildBridge({
parentOrigin,
debug: isDebugFromUrl() ? (dir, env) => console.log(dir, env) : false,
});协议概览
| 概念 | 说明 |
| ------------------- | ---------------------------------------------------------------------------------- |
| Envelope | { __playcraft__, kind, id, name, data?, ret?, msg? } — 所有消息的统一信封 |
| __playcraft__ | 发送方协议版本,与 npm 包 version 一致;不一致时控制台 soft warn,消息仍会被处理 |
| req | 请求 envelope;接收方 bridge.on 处理 |
| res | 响应 envelope(可选)。ret === 0 时 data 为结果;失败时 msg 描述原因 |
| PathEntry | { path, data } — data 恒为 string |
出站:bridge.emit(name, data) — 需要 res 的 wire name 会 await;否则立即返回。
入站:bridge.on(name, fn) — callback 返回数据则发 res;无返回则不回包。callback 支持 async / Promise。
Wire 名称统一为 ${domain}.${action} 格式,在 BridgeMethods 中维护。
API 参考
SDK 导出
| 导出 | 说明 |
| ------------------------------------------------ | --------------------------------------- |
| Bridge(emit / on) | postMessage 封装 + 出站/入站 API |
| setupChildBridge | Child 侧一键初始化 + 握手 |
| setupParentBridge / buildChildIframeUrl | Parent 侧绑定 iframe + 握手 |
| emitOne / normalizeInvokeCall 等 | 批量 emit 辅助(高级) |
| PlaycraftBridgeError / BridgeErrorCodes | 结构化错误 |
| PROTOCOL_VERSION | 当前协议版本字符串 |
| BridgeMethods / bridgeMethod | 全部 wire 名称(${domain}.${action}) |
| BRIDGE_RPC_NAMES | 需要 res 的 wire 名称列表 |
| RetCodes / errorCodeToRet / retToErrorCode | Envelope ret 与错误码映射 |
| isPlaycraftEnvelope | 校验未知 postMessage 是否为本协议 |
Wire 名称在 BridgeMethods 中维护:
import { BridgeMethods, bridgeMethod } from '@playcraft/iframe-bridge';
BridgeMethods.form.get; // 'form.get' — emit 等待 res
BridgeMethods.dialog.close; // 'dialog.close' — emit 立即返回
bridgeMethod('form', 'get'); // 'form.get'修改 wire 名称时只需改 BridgeMethods(bridgeMethod('domain', 'action'))一处;MethodMap、EventMap、InvokeMap 等均从此派生。
form.get
批量读取路径内容。
方向:Child → Parent(Child emit,Parent on)
Request string[] — path 列表,不含 data
['scene.bg', 'scene.role'];Response { paths: PathEntry[] }
{
paths: [
{ path: 'scene.bg', data: '...' },
{ path: 'scene.role', data: '...' },
];
}form.set
批量写入路径。
方向:Child → Parent(Child emit,Parent on)
Request { paths: PathEntry[] }
{
paths: [{ path: 'scene.role', data: '...' }],
}Response(ret === 0 时){ paths: string[] } — 已成功写入的 path 列表
{
paths: ['scene.role'],
}任一路径写入失败时 Parent handler 应 throw PlaycraftBridgeError,Child 侧 emit 收到 ret !== 0 与 msg。
image.aiGenerate
Request AiGenerateImagePayload
| 字段 | 类型 | 必填 | 说明 |
| ---------------------- | ---------- | ---- | -------------------------------------------------------------------------------------------------------- |
| prompt | string | | 生成提示词 |
| referenceImageBase64 | string[] | | 参考图列表;支持 data:<mime>;base64,... 或纯 base64(无前缀) |
| path | string | | theme.data 字段路径(如 gamePlay.mainScene.tableTop);提供时 Parent 将图片写入 assets/ 并更新该路径 |
| aspectRatio | string | | 宽高比(如 16:9);未传时 Parent 从 schema xplatform.ext.ratio 读取 |
| width | number | | 输出宽度(px);未传时 Parent 从 schema resolution / 默认值解析 |
| height | number | | 输出高度(px);未传时 Parent 从 schema resolution / 默认值解析 |
| fitMode | string | | contain | cover | fill;未传时 Parent 从 schema / 资产路径推断 |
| format | string | | png | webp | jpeg;未传时 Parent 从 schema mime_type 解析 |
| removeBackground | boolean | | 生成后是否抠图;未传时 Parent 从 schema ext.remove_background 读取 |
| paddingColor | string | | contain 留边色(如 transparent、#RRGGBB) |
| imageModelRef | string | | 指定生图模型 ref |
| teamId | string | | 素材库 team;未传时 Parent 使用当前活跃 team |
prompt 与 referenceImageBase64 至少提供一个。其余字段均为可选;未传时 Parent 侧与 Remix AI 生图对话框一致,从 schema xplatform 协议推导默认值后再调用 generate-image-preview。
Response string — 生成图片的 data URL(data:<mime>;base64,...)
Envelope 的 data 字段即为 data URL 字符串,Child 可直接用于 <img src>:
const imageUrl = await bridge.emit(BridgeMethods.image.aiGenerate, { prompt: '...' });
// <img src={imageUrl} alt="" />{
"kind": "res",
"name": "image.aiGenerate",
"ret": 0,
"data": "data:image/png;base64,iVBORw0KGgo..."
}
form.get/form.set的 PathEntry.data 仍为纯 base64(无data:前缀);仅image.aiGenerate的响应使用 data URL。
child.ready
方向:Child → Parent · res:无
Payload:{}
setupChildBridge 在嵌入模式下自动 emit。Parent 侧 setupParentBridge 收到后自动 emit host.init。
host.init
方向:Parent → Child · res:无
Payload:{ params?: Record<string, unknown> }
Child 侧 whenParentReady() / Parent 侧 whenChildReady() 在握手完成后 resolve。
若对端未集成 SDK,默认在 DEFAULT_HANDSHAKE_TIMEOUT_MS(10s)后仍 resolve 空 payload,并在控制台 console.warn 提示;可通过 handshakeTimeout 调整等待时间。
dialog.close
方向:Child → Parent · res:无
Payload:{ reason?: string }
Parent 用 bridge.on 注册(无 return),通常在此移除 iframe 并 bridge.destroy()。
错误码
有 res 的调用失败时 ret !== 0,msg 为错误描述;ret 与 BridgeErrorCodes 的映射见 RetCodes:
| ret | Code | 含义 |
| --- | ------------------ | ------------------ |
| 0 | — | 成功 |
| 1 | INVALID_PARAM | 参数不合法 |
| 2 | TIMEOUT | 等待响应超时 |
| 3 | ABORTED | 调用方 abort |
| 4 | REQUEST_FAILED | 通用失败 |
| 5 | INTERNAL_ERROR | Handler 内部错误 |
| 6 | PEER_UNAVAILABLE | 对端 window 不可用 |
| 7 | DESTROYED | Bridge 已销毁 |
| 8 | METHOD_NOT_FOUND | 方法未注册 |
Client 侧 bridge.emit 对有 res 的 wire name 失败时会 reject 为 PlaycraftBridgeError(由 ret 反查 code)。
Envelope 示例
请求(有 res)
{
"__playcraft__": "0.0.1",
"kind": "req",
"id": "1702819473736-0",
"name": "form.get",
"data": ["scene.bg", "scene.role"]
}成功响应
{
"__playcraft__": "0.0.1",
"kind": "res",
"id": "1702819473736-0",
"name": "form.get",
"ret": 0,
"data": { "paths": [{ "path": "scene.bg", "data": "..." }] }
}失败响应
{
"__playcraft__": "0.0.1",
"kind": "res",
"id": "1702819473736-0",
"name": "form.set",
"ret": 1,
"msg": "Invalid path: scene.unknown"
}