DOCUMENTATION · 跨域与嵌入
跨域与嵌入
---
本文回答:
COOP/COEP到底买到了什么、代价是什么、什么时候该开、部署与 iframe 嵌入怎么做。 这是本项目最容易翻车的一节:SharedArrayBuffer的报错很直白,但require-corp的封杀与same-origin的断链是静默的——页面看上去正常,只是某些资源没了、某些弹窗不工作了。
0. 30 秒结论
COOP/COEP不是"打开 SAB 的开关",而是整页的全局约束:开启后页面进入「跨域隔离」模式, 所有跨域子资源的加载规则与所有弹窗/opener 关系全部改变。- 项目决策(2026-10-01):做成「可选开关」,默认关闭(
vite.config.ts的resolveIsolationHeaders())。 需要 WASM 多线程 / 共享内存(SharedArrayBuffer)时用CROSS_ORIGIN_ISOLATION=1显式打开,当前档位 C(require-corp)。开关怎么用见 §3.1。 - 当前"开着"的实际代价是 0:
main从未使用 SAB(src/0 命中),且页面没有任何跨域子资源 (index.html/src/里 CDN 字体、第三方脚本、外链图片:0 命中,全部自托管)。 代价为 0 的前提就是"全站自托管"——所以它必须变成一条纪律(§2.1),而不是一个偶然。 - ⚠️ 纠正一个常见误区:普通(单线程)WASM 插件并不需要跨域隔离。
本项目的 WASM ABI 是单线程的:宿主只给 4 个 import,插件导出的是自己的私有
memory, 没有共享内存;docs/PLUGIN_WASM.md明写「多线程std::thread❌ 无 …… 插件天生单线程」,docs/ABI.md也写「WASM 插件默认单线程」。 真正需要隔离的是「WASM + Worker +SharedArrayBuffer/ 共享内存 /Atomics」那条路。
1. 它买到了什么
SharedArrayBuffer(SAB)只在crossOriginIsolated === true时可用,且页面必须处于 secure context (HTTPS 或localhost)。- 让
crossOriginIsolated变成true的唯一途径就是同时下发:Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp - 引擎里 SAB 的真实用途只有一个:WASM 多线程 / 零拷贝回读(归档的压测资产把多路 WASM 放进 独立 Worker,用 SAB 回读统计量)。单线程 WASM 插件与普通 JS 插件都不需要它。
- 开发/预览时这两个头由
vite.config.ts在开关打开时下发(默认关闭);且只覆盖dev与preview,与生产部署无关(见 §4)。
2. 代价矩阵(重点,逐条都会真的发生)
| 受影响的东西 | 触发机制 | 表现 | 解法 |
|---|---|---|---|
CDN 字体 / 图片 / 脚本 / CSS(<link>、<img>、<script>、@font-face 指向第三方域) | COEP: require-corp 要求每个跨域子资源自带 Cross-Origin-Resource-Policy: cross-origin(或走 CORS) | 资源被 block,控制台报 net::ERR_BLOCKED_BY_RESPONSE,字体回退、图标缺失、页面"看起来没样式" | ① 自托管(同源)最稳;② 让 CDN 返回 Cross-Origin-Resource-Policy: cross-origin;③ 换 credentialless(§3 档位 B) |
| 第三方统计 / 埋点 / 客服 / 支付 SDK | 同上(它们普遍不加 CORP) | 静默失效,没有任何报错(宿主 JS 自己吞了) | 同上;或把这类脚本挪到不带隔离的父页/独立域 |
OAuth 弹窗登录、window.open + postMessage、window.opener 通信 | COOP: same-origin 会把 opener 关系切断 | window.opener === null,回调页收不到消息,登录流程卡死 | 改整页跳转;或把 COOP 放宽为 same-origin-allow-popups |
<iframe src="第三方"> | COEP 对该 iframe 文档同样生效 | iframe 白屏 / block | 同源化、让 iframe 带 CORP、或 credentialless |
| 把本引擎嵌进别人的 iframe | 隔离无法跨非同源边界继承:父页不隔离 → 子页 crossOriginIsolated=false | 用到 SAB 的插件静默降级为结构化克隆通路(不报错,只是慢 / 功能弱) | 父页也下发两个头,且 iframe 上写 allow="cross-origin-isolated";跨域父页基本无解 |
老代码里的 document.domain = ... | 与 COOP 互斥 | 隔离失效 | 移除 |
| 嵌套 iframe(多层) | 隔离逐层要求同源或显式授权 | 内层拿不到隔离 | 减少层级;同源化 |
一句话:
require-corp会惩罚页面上任何一个你控制不了的资源,same-origin会惩罚 任何依赖 opener 的第三方流程。这两件事都不会给你一个明确的错误提示。
2.1 一旦开启(CROSS_ORIGIN_ISOLATION=1):必须遵守的纪律
隔离开着的时候,"代价 0"是被下面这些约束换来的。违反任何一条,症状都是静默的:
✅ 允许
- 所有资源同源自托管(现有做法:
index.html只引/src/main.ts,样式与字体全在本仓库)。 - 同源 iframe、同源弹窗。
❌ 禁止(会静默失效)
- 引入 CDN:Google Fonts / 图标库 / CDN 上的 JS 或 CSS / 外链图片 —— 除非该 CDN 回传
Cross-Origin-Resource-Policy: cross-origin(绝大多数不回传)。 - 引入第三方 SDK:统计、埋点、客服、支付、地图(它们不会带 CORP)。
- 依赖
window.opener的流程:OAuth 弹窗登录、window.open+postMessage回调。 需要弹窗登录就把COOP放宽为same-origin-allow-popups(会失去 SAB)。 - 用
document.domain = ...做跨子域(与 COOP 互斥)。
📌 一旦要引入上述任何一种,先回来读 §3 重新选档位,别直接加依赖 —— 因为失败时控制台 可能什么都不说。
可选:把"豁免"集中成一处(若确实需要第三方资源)
- 该资源能自托管 → 自托管(首选);
- 该资源必须远程 → 前面加一层自家反代(同源转发),而不是直接引外域;
- 整类第三方资源太多、又必须共存 → 改用 档位 B(
credentialless);但它的浏览器兼容性有限 (Firefox 桌面版 119+ 才支持,Safari 与 Firefox for Android 完全不支持,见 §3 脚注), 不支持的浏览器上会拿不到 SAB。取舍见下。
3. 三个档位(按需选一,别默认全开)
| 档位 | 响应头 | 能拿到 SAB | 对第三方资源 | 适用 |
|---|---|---|---|---|
| A. 不开(✅ 本项目默认) | 无 | ❌ | ✅ 完全不受影响 | 不做 WASM 多线程 / 零拷贝的一切场景(默认) |
| B. credentialless(折中) | COOP: same-origin + COEP: credentialless | ✅(仅支持的浏览器,见脚注) | ✅ 允许无凭据的跨域 no-cors 资源(图片/字体/脚本)通过 | 想要 SAB、又必须拉 CDN 资源;兼容性有限(见下方脚注) |
| C. require-corp(开启开关时的默认档位) | COOP: same-origin + COEP: require-corp | ✅ | ❌ 跨域资源必须自带 CORP 或 CORS | 全部资源自托管 + 需要 SAB。前提是 §2.1 的纪律被遵守 |
脚注 · 档位 B(COEP: credentialless)的浏览器兼容性
| 浏览器 | 起始版本 |
|---|---|
| Chrome / Edge | 96+ |
| Chrome for Android | 96+ |
| Opera | 82+ |
| Firefox(桌面) | 119+ |
| Firefox for Android | ❌ 不支持 |
| Safari / Safari on iOS | ❌ 不支持 |
数据来源:MDN
browser-compat-data8.1.3(键http.headers['Cross-Origin-Embedder-Policy'].credentialless)。 不支持的浏览器会忽略这个无法识别的 COEP 取值:页面不报错、照常打开,但crossOriginIsolated === false→ SAB 不可用(效果等同档位 A)。所以档位 B 只在"主流桌面浏览器 + 能接受 Safari/移动 Firefox 降级"时才划算。
3.1 开关用法(vite.config.ts)
开关只影响 dev / preview(生产响应头由托管方回传,见 §4)。默认关闭。
npm run dev # 默认:关闭(不下发任何隔离头)
CROSS_ORIGIN_ISOLATION=1 npm run dev # 打开(档位 C,require-corp)
CROSS_ORIGIN_ISOLATION=1 CROSS_ORIGIN_EMBEDDER_POLICY=credentialless npm run dev # 档位 B
CROSS_ORIGIN_ISOLATION=1 CROSS_ORIGIN_OPENER_POLICY=same-origin-allow-popups npm run dev # 放宽 COOP(仍失去 SAB)| 环境变量 | 取值 | 默认 |
|---|---|---|
CROSS_ORIGIN_ISOLATION | 显式真值 1/true/on/yes = 开;其余(含未设置、0/false/off/no)= 关 | 关 |
CROSS_ORIGIN_EMBEDDER_POLICY | require-corp / credentialless | require-corp |
CROSS_ORIGIN_OPENER_POLICY | same-origin / same-origin-allow-popups | same-origin |
恢复
archive/original里的压测资产(WASM 多线程 / SAB)时,必须用CROSS_ORIGIN_ISOLATION=1启动, 否则那条通路会静默降级为结构化克隆。改完请同步更新本节与 §0 的结论。
tests/docs-consistency.test.ts会真实加载vite.config.ts(不是正则猜)验证:默认关闭、=1时下发require-corp、=0时不下发、两个档位变量能透传; 并检查本文档是否写明了开关名与当前档位。
档位 B/C 只对支持该特性的浏览器生效;不支持时页面照常打开,只是
crossOriginIsolated为false。
4. 生产部署清单
vite.config.ts 的头只在 dev/preview 生效,dist/ 部署到哪、由谁回传这两个头,是运维问题。
| 托管方式 | 怎么加头 | 备注 |
|---|---|---|
| Nginx | add_header Cross-Origin-Opener-Policy same-origin always;add_header Cross-Origin-Embedder-Policy require-corp always; | 放在 HTML 文档的 location 上;always 保证错误页也带 |
| Caddy | header { Cross-Origin-Opener-Policy same-origin ... } | 同上 |
| Netlify | 根目录 _headers 文件 | 需要随 dist/ 一起发布 |
| Vercel | vercel.json 的 headers 段 | 需按路径匹配 HTML |
| GitHub Pages | 无法自定义响应头 | 想开隔离只能前面套一层 Cloudflare Worker / 自有反向代理 |
| 对象存储 + CDN(OSS/COS/S3+CDN) | 多数默认不支持自定义响应头,或会剥掉 | 优先选档位 A;必要时走反向代理 |
跨域部署(页面在 A 域、public/plugins/** 在 B 域) | 除了 COEP,还得给 WASM/剧情资源配 CORS | 否则 fetch 被拦,与 SAB 无关也会挂 |
上线后 10 秒自检(控制台):
crossOriginIsolated // 期望:按选定档位为 true / false(不是"必须 true")
document.querySelector('link[rel=stylesheet]') // 结合 Network 面板看有没有 ERR_BLOCKED_BY_RESPONSE5. 把引擎嵌进 iframe
引擎自身不使用 iframe(插件 UI 是 DOM 挂载在宿主给的容器里),但"把引擎放进 iframe"是很常见的集成方式, 两种拓扑的结论完全不同:
5.1 同源 iframe(父页与引擎同域)
- 可行,但若要 SAB:父页也必须下发两个头,且 iframe 上写
allow="cross-origin-isolated"; - 跨域隔离不会从父页自动继承给子页。
5.2 跨域 iframe(父页在别的域)
window.__storyRuntime拿不到:跨域窗口无法直接访问对方属性。必须改用postMessage桥;- 隔离:父页不下发头 → 子页不隔离 → SAB 相关能力降级(不报错);
- 存档会丢:
localStorage在第三方 iframe 情境下受浏览器存储分区限制,save/load可能不可用; 跨域集成应把存档交给父页(走postMessage传快照,由父页持久化)。
postMessage 桥的最小契约(当前仓库未实现,属于待建能力):
父 → 子 : { type:'load', url, autoStart } / { type:'start' } / { type:'save', slot } / { type:'event.subscribe', name }
子 → 父 : { type:'ready' } / { type:'event', name, payload } / { type:'state', snapshot } / { type:'error', message }6. 症状 → 原因速查
| 症状 | 最可能原因 |
|---|---|
字体/图标/样式"没加载",控制台 ERR_BLOCKED_BY_RESPONSE | COEP 是 require-corp,但该跨域资源没带 CORP |
| 第三方统计/客服静默失效 | 同上(对方不加 CORP) |
OAuth 弹窗回调收不到 / window.opener 为 null | COOP 是 same-origin |
| iframe 里的引擎"看起来正常但很慢" | 父页未隔离 → crossOriginIsolated=false → SAB 通路降级 |
跨域 iframe 里 window.__storyRuntime 是 undefined | 跨域窗口属性不可读,需要 postMessage 桥 |
SharedArrayBuffer is not defined | 没隔离,或缺 HTTPS/localhost |
本地 npm run dev 一切正常,上线就出问题 | vite.config.ts 的头没在生产侧复刻 |
7. 如果将来真的要 SAB,推荐做法
- 默认 A 档位,只有确实要跑 SAB 的部署(如压测/多线程 WASM)才切到 B 或 C;
- 切档位前,先扫一遍页面上所有你不控制的资源,逐个确认能否自托管或带 CORP;
- 把"隔离是否生效"做成启动期可见的诊断(而不是等某个插件行为异常才回头查);
- 跨域 iframe 集成一律走
postMessage,不要依赖window.__storyRuntime。
相关链接
- FRONTEND.md:把引擎当库嵌入自己的页面。
- BUILD.md:构建与产物、部署步骤。
- FAQ.md:常见问题速查。
- PLUGIN_WASM.md:WASM 插件与多线程相关限制。
- 头配置来源:vite.config.ts(仅 dev/preview)。