feat(embed): versioned loader with postMessage whitelist

packages/embed-loader:手写零依赖加载器(esbuild bundle+minify→dist/v1/loader.js,
IIFE target es2018 4.7KB)。自发现 EMBED_ORIGIN(currentScript 兜底扫描)、
data-slot 必填缺失静默 no-op、__photoliveEmbedV1 标记去重+同槽去重、
窄屏 <480px 强制 sm+默认收起、postMessage 双向白名单(source+origin 严格
校验,resize 夹取 60-640)、sessionStorage 收起记忆、零全局零 console
全静默。sync 脚本携 sha256 校验拷贝至 viewer publicDir(两份产物入库);
src/params.js 为 loader/单测共用参数源(11 vitest 用例)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-20 22:31:40 +08:00
parent aee4b7ac46
commit ddb4c8175f
10 changed files with 637 additions and 0 deletions
+243
View File
@@ -0,0 +1,243 @@
// packages/embed-loader/src/loader.js
// PhotoLive 悬浮轮播组件加载器 v1 —— 对外发布物(esbuild 打包为 dist/v1/loader.js,
// 由 viewer 静态服务,宿主以 <script src="{EMBED_ORIGIN}/embed/v1/loader.js" data-slot> 引入)。
// 约束:零依赖、零全局(仅版本标记属性)、无 console、一切错误静默不抛(A11 不破坏宿主)。
// 安全模型(postMessage 双向白名单 / sandbox 取舍)见同包 README-embed.md。
import {
parseEmbedConfig,
buildEmbedUrl,
clampHeight,
SIZE_WIDTH,
} from './params.js';
(function () {
'use strict';
try {
var w = window;
// 已初始化去重:同一页面重复引入 loader 只跑一次(不同 slot 的多个标签在首次运行时全部扫描)。
if (w.__photoliveEmbedV1) return;
w.__photoliveEmbedV1 = '1';
var COLLAPSE_KEY = 'pl-embed-collapsed:';
var Z_INDEX = '2147483000';
var DEFAULT_HEIGHT = 96; // 首条 resize 消息前的占位高度
function readCollapsed(slot) {
try {
return w.sessionStorage.getItem(COLLAPSE_KEY + slot) === '1';
} catch {
return false;
}
}
function writeCollapsed(slot, collapsed) {
try {
if (collapsed) w.sessionStorage.setItem(COLLAPSE_KEY + slot, '1');
else w.sessionStorage.removeItem(COLLAPSE_KEY + slot);
} catch {
/* Safari 隐私模式等 storage 异常:静默放弃记忆 */
}
}
// 自发现:currentScript(defer 下同步执行仍可用)→ 兜底最后一个匹配标签。
function selfScript() {
if (document.currentScript && document.currentScript.src) return document.currentScript;
var all = document.querySelectorAll('script[src*="/embed/v1/loader.js"]');
return all.length ? all[all.length - 1] : null;
}
function originOf(el) {
try {
return new URL(el.src, w.location.href).origin;
} catch {
return null;
}
}
var instances = [];
var seenSlots = {};
function handleLabel(lang) {
if (lang === 'zh-CN') return '展开照片轮播';
if (lang === 'en') return 'Show photo carousel';
return 'Show photo carousel';
}
function createHandle(inst) {
var h = document.createElement('div');
var side = inst.config.position === 'left' ? 'left' : 'right';
h.setAttribute('role', 'button');
h.setAttribute('tabindex', '0');
h.setAttribute('aria-label', handleLabel(inst.config.lang));
var s = h.style;
s.position = 'fixed';
s.bottom = '24px';
s[side] = '16px';
s.width = '44px';
s.height = '44px';
s.borderRadius = '50%';
s.background = 'rgba(15,23,42,0.88)';
s.boxShadow = '0 2px 10px rgba(0,0,0,0.3)';
s.zIndex = Z_INDEX;
s.cursor = 'pointer';
s.display = 'flex';
s.alignItems = 'center';
s.justifyContent = 'center';
h.innerHTML =
'<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" focusable="false">' +
'<path fill="%23ffffff" d="M9 2 7.17 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2h-3.17L15 2H9zm3 15c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5z"/></svg>';
function activate(ev) {
if (ev.type === 'keydown') {
var k = ev.key;
if (k !== 'Enter' && k !== ' ' && k !== 'Spacebar') return;
ev.preventDefault();
}
expandInstance(inst);
// 通知 iframe 内部恢复轮播/可见行为;目标源严格限定 iframe 源(绝不 '*')。
try {
if (inst.frame.contentWindow) {
inst.frame.contentWindow.postMessage(
{ photoliveEmbed: true, type: 'expand' },
inst.frameOrigin
);
}
} catch {
/* 静默 */
}
}
h.addEventListener('click', activate);
h.addEventListener('keydown', activate);
return h;
}
function collapseInstance(inst) {
if (inst.collapsed) return;
inst.collapsed = true;
inst.wrapper.style.height = '0px';
inst.wrapper.style.boxShadow = 'none';
if (!inst.handle) {
inst.handle = createHandle(inst);
document.body.appendChild(inst.handle);
}
inst.handle.style.display = 'flex';
writeCollapsed(inst.config.slot, true);
}
function expandInstance(inst) {
if (!inst.collapsed) return;
inst.collapsed = false;
inst.wrapper.style.height = inst.height + 'px';
inst.wrapper.style.boxShadow = '0 4px 16px rgba(0,0,0,0.18)';
if (inst.handle) inst.handle.style.display = 'none';
writeCollapsed(inst.config.slot, false);
}
function createInstance(origin, config) {
var wrapper = document.createElement('div');
var side = config.position === 'left' ? 'left' : 'right';
var ws = wrapper.style;
ws.position = 'fixed';
ws.bottom = '24px';
ws[side] = '16px';
ws.width = SIZE_WIDTH[config.size] + 'px';
ws.height = DEFAULT_HEIGHT + 'px';
ws.zIndex = Z_INDEX;
ws.borderRadius = '12px';
ws.boxShadow = '0 4px 16px rgba(0,0,0,0.18)';
ws.overflow = 'hidden';
var frame = document.createElement('iframe');
// sandbox 必须含 allow-same-origin(评审修正):否则 iframe 为 opaque 源 "null",
// loader 的「消息 origin === iframe 源」白名单会拒掉自己的 resize/collapse,
// 且 iframe 内取公开 API 需 ACAO *。宿主↔iframe 天然跨源,same-origin 仅使
// iframe 保有项目域源,白名单按源+source 双重严格校验兜底。
frame.setAttribute(
'sandbox',
'allow-scripts allow-popups allow-popups-to-escape-sandbox allow-same-origin'
);
frame.setAttribute('src', buildEmbedUrl(origin, config.slot, config));
frame.setAttribute('referrerpolicy', 'no-referrer-when-downgrade');
frame.setAttribute('title', 'PhotoLive photos');
var fs = frame.style;
fs.width = '100%';
fs.height = '100%';
fs.border = '0';
fs.display = 'block';
wrapper.appendChild(frame);
document.body.appendChild(wrapper);
var inst = {
config: config,
wrapper: wrapper,
frame: frame,
// 白名单基准源:从 iframe src 推导(构建后即固定,等于 EMBED_ORIGIN)。
frameOrigin: originOf(frame) || origin,
height: DEFAULT_HEIGHT,
collapsed: false,
handle: null,
};
instances.push(inst);
// 收起记忆 / 窄屏默认收起:会话内不反复弹出(需求 9)。
if (readCollapsed(config.slot) || config.collapsedDefault) collapseInstance(inst);
}
// postMessage 白名单:仅 photoliveEmbed===true 且 type∈{resize,collapse,expand},
// 且 event.source 命中已登记 iframe 且 event.origin 严格等于该 iframe 的源。
w.addEventListener('message', function (event) {
try {
var data = event.data;
if (!data || data.photoliveEmbed !== true) return;
var type = data.type;
if (type !== 'resize' && type !== 'collapse' && type !== 'expand') return;
for (var i = 0; i < instances.length; i++) {
var inst = instances[i];
if (!inst.frame.contentWindow || event.source !== inst.frame.contentWindow) continue;
if (event.origin !== inst.frameOrigin) return;
if (type === 'resize') {
var h = clampHeight(data.height);
if (h !== null) {
inst.height = h;
if (!inst.collapsed) inst.wrapper.style.height = h + 'px';
}
} else if (type === 'collapse') {
collapseInstance(inst);
} else {
expandInstance(inst);
}
return;
}
} catch {
/* 静默 */
}
});
function init() {
var self = selfScript();
if (!self) return;
var embedOrigin = originOf(self);
if (!embedOrigin) return;
var narrow = false;
try {
narrow = !!w.matchMedia && w.matchMedia('(max-width: 479px)').matches;
} catch {
narrow = false;
}
// 扫描全部 loader 标签:单标签单槽为主路径,多标签(多槽/集中引入)一并覆盖;
// 同 data-slot 重复出现时后者静默忽略。
var tags = document.querySelectorAll('script[src*="/embed/v1/loader.js"]');
for (var i = 0; i < tags.length; i++) {
var config = parseEmbedConfig(tags[i].dataset, { narrow: narrow });
if (!config.slot) continue;
if (seenSlots[config.slot]) continue;
seenSlots[config.slot] = true;
createInstance(embedOrigin, config);
}
}
if (document.body) {
init();
} else {
document.addEventListener('DOMContentLoaded', init);
}
} catch {
/* 静默:任何异常不得破坏宿主页 */
}
})();