@youmind-ai/openapi-bridge
v1.0.0
Published
让不受信的页面运行时调用 YouMind OpenAPI(`/openapi/v1/*`),同时保证 **API Key 自始至终不进入页面代码**。桥本身与具体业务无关;当前用例是 AI 生成的网页(Webpage craft)。覆盖两个运行环境,页面代码完全同一份:
Readme
@youmind-ai/openapi-bridge
让不受信的页面运行时调用 YouMind OpenAPI(/openapi/v1/*),同时保证 API Key 自始至终不进入页面代码。桥本身与具体业务无关;当前用例是 AI 生成的网页(Webpage craft)。覆盖两个运行环境,页面代码完全同一份:
- 生产:youweb 的 sandbox iframe,经 postMessage 桥由宿主代为执行;
- 本地开发(youdesktop 沙箱等):经本地凭证代理(dev proxy)执行。
环境判别只有一条规则:读到 dev 代理配置即开发环境,否则即生产环境;两种环境下失败都直接报错,没有回退和探测。
工作原理
iframe(不受信代码) youweb(宿主) youapi
┌─────────────────────┐ MessageChannel ┌──────────────────────┐ HTTPS ┌────────┐
│ youmind.fetch(path) │ ────────────────► │ executor: │ ────────► │ openapi│
│ 只传 path/method/ │ ◄──────────────── │ 路径白名单校验 │ ◄──────── │ /v1/* │
│ headers/body │ 序列化的响应 │ 剥离敏感头 │ └────────┘
└─────────────────────┘ │ 附加 x-api-key │
└──────────────────────┘握手为三步:client 携带全新 MessagePort 定时重试 connect → 宿主校验 event.source 是所服务 iframe 的 contentWindow 后回 ready → client 只采用第一条 ready 的通道并回 ack,宿主只把收到 ack 的通道设为活跃通道。此后请求/响应全部走该 port,window 级消息只用于握手。
安全边界:
- 凭证隔离:API Key 只存在于宿主侧 executor 闭包内,协议消息里没有任何凭证字段;
- 路径与方法白名单:默认 executor 只放行
/openapi/v1/下的GET/POST;路径只做一次百分号解码,残留编码、点分段、反斜杠、双斜杠、控制字符与 fragment 一律拒绝; - 宿主头所有权:iframe 传来的凭证头、Host/CORS/长度/编码头与逐跳连接头一律丢弃;缓冲响应同样剥离过期长度/编码头与逐跳头;
- 来源校验:宿主只接受指定 iframe contentWindow 发来的握手,兼容现有 sandbox(无
allow-same-origin,opaque origin)模型; - 资源边界:默认最多保留 4 条待 ack 通道与 8 个并发请求;请求头 128 个/64 KiB、请求体 16 MiB、响应头 128 个/64 KiB、响应体 32 MiB,单请求最长 30 秒;
limits只能把这些上限调低; - 运行时校验:MessagePort 上的消息不信任 TypeScript 类型;畸形请求、重复 request id、超限请求均在调用 executor 前拒绝;
- 错误脱敏:回传 iframe 的错误只含 name/message,不带 stack。
用法
宿主侧(youweb)
import { attachOpenApiBridge, createOpenApiExecutor } from '@youmind-ai/openapi-bridge/host';
const bridge = attachOpenApiBridge(iframeElement, {
execute: createOpenApiExecutor({
baseUrl: apiBaseUrl,
// API Key 的获取/缓存/轮换策略由 youweb 决定,每次请求时调用
getApiKey: () => fetchScopedApiKey(),
}),
});
// iframe 卸载时必须释放
bridge.dispose();需要完全自定义执行逻辑时可不用 createOpenApiExecutor,直接实现 BridgeRequestExecutor;实现必须响应 context.signal,使宿主的取消、重连、dispose 与 30 秒超时能及时终止底层工作。
iframe 侧(生成的网页)
import { installOpenApiBridgeGlobal } from '@youmind-ai/openapi-bridge/client';
// 由 youweb 用自身构建打进注入脚本;页面代码只使用全局 youmind 对象
installOpenApiBridgeGlobal();
// 生成的网页代码:
const response = await youmind.fetch('/openapi/v1/listMaterials', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ boardId }),
});
const data = await response.json();youmind.fetch 与标准 fetch 语义对齐:返回 Response、HTTP 错误状态不 reject、支持 AbortSignal、body 支持 string / Blob / ArrayBuffer / FormData / URLSearchParams 等非流式类型。第一个参数只接受以 / 开头的站内路径,目标域名由宿主决定。
环境判别与页面侧形态
运行时不做环境嗅探,也没有任何硬编码端点。判别式是开发环境注入的代理配置:配置存在 → 开发(直连该端点,ready 阶段一次健康检查,不可达报错并提示启动 youmind proxy);配置不存在 → 生产(postMessage 桥,宿主未应答/版本不匹配报错)。
页面侧在两个环境加载的东西并不相同,但暴露同一个全局接口契约:
youmind.fetch(path, init): Promise<Response>; // path 为 / 开头的站内路径,语义与 fetch 对齐
youmind.ready: Promise<void>;- 生产:youweb 用自身构建把
@youmind-ai/openapi-bridge/client打进注入脚本,调用installOpenApiBridgeGlobal();无配置 → 桥; - 开发:页面不加载本包。
youmind proxy --write-config按代理实际监听地址写出一个小型静态 shim(youmind.dev.js,模板见下文「本地开发」),直接定义同形的youmind全局并写入__YOUMIND_BRIDGE_CONFIG__,页面一个<script src="/youmind.dev.js">引用即可。
createOpenApiBridgeClient 的配置判别是这条规则的权威实现(解析顺序:显式 options.endpoint > __YOUMIND_BRIDGE_CONFIG__.endpoint > 无 → 桥);dev shim 是它在开发环境的极简等价物——白名单、凭证、头剥离等所有真正的规则都在代理/宿主侧服务端强制,页面侧再简单也破坏不了契约。installOpenApiBridgeGlobal 幂等:先装先赢(哨兵 __youmindBridgeClient,dev shim 也参与同一哨兵),页面自带的 install 调用不会覆盖环境注入的选择;前提是环境注入方把自己置于 <head> 顶部、先于一切页面脚本执行——youweb srcDoc 与发布管线都由我们控制注入位置,必须遵守。
本地开发(youdesktop 沙箱)
dev 代理由 youdesktop 的 Rust youmind proxy 提供(凭证读沙箱 trusted_env 注入的 YOUMIND_API_KEY + YOUMIND_BASE_URL);本包不含代理实现,只定义契约:协议常量(PROTOCOL_VERSION / DEV_PROXY_HEALTH_PATH / DEV_PROXY_SERVICE_NAME / DEFAULT_DEV_PROXY_PORT)+ 客户端健康检查 + 下述安全边界。健康检查必须返回 {"service":"youmind-openapi-bridge-proxy","version":1},完整 client 与静态 shim 都同时校验 service 和 protocol version;缺字段或版本不匹配时 ready 必须 reject,不得发送业务请求。
dev 侧的全部脚手架 = 起代理 + 写 shim(--write-config,按实际监听地址生成,端口任意),页面加一个 <script src="/youmind.dev.js">。shim 的权威模板:
// youmind.dev.js — generated by `youmind proxy --write-config`; never ship to production.
(function () {
var g = globalThis;
// 防泄漏保障一:非回环源不安装——即使被误发布,shim 在生产域名下直接 no-op
var host = g.location && g.location.hostname;
if (host !== 'localhost' && host !== '127.0.0.1' && host !== '[::1]') return;
// 防泄漏保障二:与完整运行时共用安装哨兵,先装先赢(环境注入方位于 head 顶部先行执行)
if (g.__youmindBridgeClient) return;
var endpoint = 'http://127.0.0.1:9686'; // 代理实际监听地址,由代理写入
var protocolVersion = 1; // 与 @youmind-ai/openapi-bridge 的 PROTOCOL_VERSION 同步
g.__YOUMIND_BRIDGE_CONFIG__ = { endpoint: endpoint };
function unavailable() {
return new Error('OpenAPI bridge dev proxy is not reachable at ' + endpoint + '. Ensure `youmind proxy` is running.');
}
var ready = fetch(endpoint + '/.youmind-bridge/health')
.catch(function () { throw unavailable(); })
.then(function (response) {
if (!response.ok) throw unavailable();
return response.json().catch(function () { throw unavailable(); });
})
.then(function (health) {
if (!health || health.service !== 'youmind-openapi-bridge-proxy') throw unavailable();
if (health.version !== protocolVersion) {
throw new Error(
'OpenAPI bridge protocol version mismatch: proxy=' +
String(health.version) +
', client=' +
protocolVersion,
);
}
});
ready.catch(function () {}); // 避免页面只用 fetch 前出现 unhandled rejection
var client = {
fetch: function (path, init) {
if (typeof path !== 'string' || path.charAt(0) !== '/') {
return Promise.reject(new TypeError('OpenAPI bridge fetch only accepts an in-site path starting with "/"'));
}
return ready.then(function () { return fetch(endpoint + path, init); });
},
ready: ready,
dispose: function () {},
};
g.__youmindBridgeClient = client;
g.youmind = { fetch: client.fetch, ready: client.ready };
})();防泄漏是三重机制,不是约定——Vite 等构建工具会把 public/ 原样拷进产物,"生产不携带该文件"不能只靠嘱咐:
- shim 自禁用:非回环 hostname 直接 return(生产 srcDoc iframe 的 hostname 为空,同样命中),泄漏文件在生产环境是死代码;
- 安装哨兵:shim 与
installOpenApiBridgeGlobal共用__youmindBridgeClient,先装先赢;环境注入方(youweb srcDoc / 发布管线)必须把注入脚本置于<head>顶部、先于一切页面脚本执行; - 发布/入库管线按标记(文件名
youmind.dev.js或首行生成标记注释)机械剔除该文件与其 script 标签。
dev server 页面、无头浏览器自动化测试直接可用;Node 侧测试可注入 __YOUMIND_BRIDGE_CONFIG__ 后使用本包 client,或直接 fetch 代理。DEFAULT_DEV_PROXY_PORT(9686)只是代理服务器自己的默认监听端口,页面侧从不引用它。
代理实现必须满足的契约(macOS 沙箱不是网络命名空间,回环端口对整机可见,必须自防):
- 网络边界:仅绑定
127.0.0.1;Host 头必须是回环地址(DNS rebinding 防御);Origin 存在且非 localhost 族 → 403 且不带任何 CORS 头; - CORS(浏览器强制,必须实现):页面源(如
http://localhost:5173)与代理跨源,JSON POST 会触发 OPTIONS 预检。对回环 Origin:预检回 204 +Access-Control-Allow-Origin: <反射该 Origin>+Access-Control-Allow-Methods: GET, POST, OPTIONS+Access-Control-Allow-Headers: <反射 Access-Control-Request-Headers>+Access-Control-Max-Age;所有已通过回环 Origin 校验的响应——含健康检查与 403/413/502 错误——都带Access-Control-Allow-Origin反射与Vary: Origin;被 Origin gate 拒绝的非回环请求仍返回不带 CORS 头的 403;不发Allow-Credentials(凭证由代理侧附加,浏览器凭证无关); - 请求处理:只转发
/openapi/v1/下的GET/POST;路径白名单与凭证/宿主头/逐跳头处理和生产宿主同规则(参照createOpenApiExecutor与 host 的传输层剥离逻辑);请求头 64 KiB、请求体 16 MiB、响应体 32 MiB,上游请求 30 秒超时;响应剥离过期与逐跳头后按缓冲字节重写content-length。
反模式警告:不要用 VITE_* / NEXT_PUBLIC_* 之类 build-time env 把 YOUMIND_API_KEY 注入页面——key 会被烘进构建产物,页面一发布就泄漏。凭证只应存在于代理进程里。
集成注意
本包只提供机制,youweb / 生成侧的接线是后续工作:
- client 脚本进入 iframe:本包不含注入逻辑。srcDoc 场景需要 youweb 用自身构建把 client 打成内联注入脚本(可参考现有
injectContextInfo的做法,但那条链路目前只注入window.contextInfo,且只覆盖有 context 的 srcDoc 分支);URL 直连 CDN 的场景需要生成侧在 HTML 里内置该脚本。开发环境页面不加载本包(见上文 shim)。未注入的页面里不存在全局youmind。 - attach 绑定 iframe 元素实例:React 重挂载出新 iframe 元素(如 context / url 变化触发重建)时,旧桥必须
dispose()并对新元素重新attachOpenApiBridge,否则event.source校验永远不匹配。建议在 ref callback / effect 中随元素生命周期管理。 - 只能挂在无
allow-same-origin的 sandbox iframe 上(现有 Webpage 渲染即如此):桥的凭证隔离依赖 iframe 处于 opaque origin;把桥挂到非沙箱或 same-origin 的 frame 等于把 API Key 能力交给其中的任意代码。 - youapi 的 CORS 配置需允许 youweb origin 携带
x-api-key头访问/openapi/v1/*。 - 响应整体缓冲后回传,不支持流式响应 / SSE;不适合超大响应体。
入口
| 入口 | 内容 | 使用方 |
|------|------|--------|
| @youmind-ai/openapi-bridge | 协议类型与常量 | 各方共享 |
| @youmind-ai/openapi-bridge/client | installOpenApiBridgeGlobal / createOpenApiBridgeClient / createIframeBridgeClient / createHttpBridgeClient | 页面内 |
| @youmind-ai/openapi-bridge/host | attachOpenApiBridge / createOpenApiExecutor | youweb |
client 与 host 入口刻意分离,避免宿主逻辑被打进注入页面的脚本。包内全部是浏览器代码;dev 代理的实现在 youdesktop 的 youmind CLI,本包只提供其行为契约(协议常量与本 README)。
