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:
@@ -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 {
|
||||
/* 静默:任何异常不得破坏宿主页 */
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,79 @@
|
||||
// packages/embed-loader/src/params.js
|
||||
// 纯函数参数模块:loader(esbuild 打包内联)与 vitest 单测共用的唯一参数源。
|
||||
// 取值域与缺省严格对齐契约 EmbedSlotSettings(packages/contracts/openapi.yaml),
|
||||
// 非法/缺省一律静默回退——loader 对宿主零报错(A11「不破坏宿主」)。
|
||||
|
||||
export const POSITION_VALUES = ['left', 'right'];
|
||||
export const SIZE_VALUES = ['sm', 'md'];
|
||||
export const LANG_VALUES = ['auto', 'zh-CN', 'en'];
|
||||
|
||||
export const DEFAULTS = {
|
||||
position: 'right',
|
||||
size: 'md',
|
||||
intervalMs: 5000,
|
||||
lang: 'auto',
|
||||
collapsedDefault: false,
|
||||
};
|
||||
|
||||
/** size → 宿主侧 wrapper 宽度(px)。sm 为窄屏降级档。 */
|
||||
export const SIZE_WIDTH = { sm: 280, md: 340 };
|
||||
|
||||
export const INTERVAL_MIN = 3000;
|
||||
export const INTERVAL_MAX = 30000;
|
||||
|
||||
/** resize 消息高度夹取范围(px),越界/非数值返回 null 表示忽略。 */
|
||||
export const HEIGHT_MIN = 60;
|
||||
export const HEIGHT_MAX = 640;
|
||||
|
||||
function oneOf(allowed, value, fallback) {
|
||||
return allowed.indexOf(value) >= 0 ? value : fallback;
|
||||
}
|
||||
|
||||
/** 轮播间隔(ms):解析失败取缺省,超界夹取到 [3000, 30000]。 */
|
||||
export function normalizeInterval(value) {
|
||||
const n = parseInt(value, 10);
|
||||
if (isNaN(n)) return DEFAULTS.intervalMs;
|
||||
return Math.min(INTERVAL_MAX, Math.max(INTERVAL_MIN, n));
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析宿主 script 标签 data-* 配置(dataset 形如 {slot, lang, position, interval})。
|
||||
* opts.narrow=true(viewport <480px)时强制 sm 尺寸 + 默认收起(计划评审缺口:移动端可用)。
|
||||
* data-slot 缺失/空白 → slot 为 null,调用方须静默 no-op。
|
||||
*/
|
||||
export function parseEmbedConfig(dataset, opts) {
|
||||
const ds = dataset || {};
|
||||
const slot = typeof ds.slot === 'string' ? ds.slot.replace(/^\s+|\s+$/g, '') : '';
|
||||
const narrow = !!(opts && opts.narrow);
|
||||
return {
|
||||
slot: slot || null,
|
||||
lang: oneOf(LANG_VALUES, ds.lang, DEFAULTS.lang),
|
||||
position: oneOf(POSITION_VALUES, ds.position, DEFAULTS.position),
|
||||
intervalMs: normalizeInterval(ds.interval),
|
||||
size: narrow ? 'sm' : oneOf(SIZE_VALUES, ds.size, DEFAULTS.size),
|
||||
collapsedDefault: narrow ? true : DEFAULTS.collapsedDefault,
|
||||
};
|
||||
}
|
||||
|
||||
/** iframe src:{origin}/embed/{slot}?lang=&pos=&interval=(槽 token 编码防注入)。 */
|
||||
export function buildEmbedUrl(origin, slot, config) {
|
||||
return (
|
||||
origin +
|
||||
'/embed/' +
|
||||
encodeURIComponent(slot) +
|
||||
'?lang=' +
|
||||
encodeURIComponent(config.lang) +
|
||||
'&pos=' +
|
||||
encodeURIComponent(config.position) +
|
||||
'&interval=' +
|
||||
String(config.intervalMs)
|
||||
);
|
||||
}
|
||||
|
||||
/** resize 高度夹取:非有限数值/缺失 → null(忽略该消息)。 */
|
||||
export function clampHeight(value) {
|
||||
if (value === null || value === undefined) return null;
|
||||
const n = Number(value);
|
||||
if (isNaN(n) || !isFinite(n)) return null;
|
||||
return Math.min(HEIGHT_MAX, Math.max(HEIGHT_MIN, Math.round(n)));
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// packages/embed-loader/src/params.test.js
|
||||
// 纯函数单测:data-* 解析/缺省回退/夹取、窄屏强制、URL 拼接、高度夹取。
|
||||
// DOM/宿主交互行为由 Task 4 Playwright 宿主页覆盖(计划注明 node 模拟 DOM 太薄)。
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildEmbedUrl,
|
||||
clampHeight,
|
||||
normalizeInterval,
|
||||
parseEmbedConfig,
|
||||
} from './params.js';
|
||||
|
||||
describe('parseEmbedConfig', () => {
|
||||
it('空 dataset 全缺省(slot null → 调用方 no-op)', () => {
|
||||
const c = parseEmbedConfig({});
|
||||
expect(c).toEqual({
|
||||
slot: null,
|
||||
lang: 'auto',
|
||||
position: 'right',
|
||||
intervalMs: 5000,
|
||||
size: 'md',
|
||||
collapsedDefault: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('data-slot 保留并去首尾空白;非字符串(缺失)为 null', () => {
|
||||
expect(parseEmbedConfig({ slot: ' tok ' }).slot).toBe('tok');
|
||||
expect(parseEmbedConfig({}).slot).toBeNull();
|
||||
expect(parseEmbedConfig({ slot: ' ' }).slot).toBeNull();
|
||||
});
|
||||
|
||||
it('lang/position 白名单外静默回退缺省', () => {
|
||||
expect(parseEmbedConfig({ slot: 'a', lang: 'zh-CN' }).lang).toBe('zh-CN');
|
||||
expect(parseEmbedConfig({ slot: 'a', lang: 'en' }).lang).toBe('en');
|
||||
expect(parseEmbedConfig({ slot: 'a', lang: 'fr' }).lang).toBe('auto');
|
||||
expect(parseEmbedConfig({ slot: 'a', position: 'left' }).position).toBe('left');
|
||||
expect(parseEmbedConfig({ slot: 'a', position: 'top' }).position).toBe('right');
|
||||
});
|
||||
|
||||
it('interval 解析失败取缺省、越界夹取到 [3000, 30000]', () => {
|
||||
expect(normalizeInterval('8000')).toBe(8000);
|
||||
expect(normalizeInterval('100')).toBe(3000);
|
||||
expect(normalizeInterval('999999')).toBe(30000);
|
||||
expect(normalizeInterval('abc')).toBe(5000);
|
||||
expect(normalizeInterval(undefined)).toBe(5000);
|
||||
expect(parseEmbedConfig({ slot: 'a', interval: '2500' }).intervalMs).toBe(3000);
|
||||
});
|
||||
|
||||
it('窄屏(<480px):强制 sm 尺寸 + 默认收起,覆盖 dataset 尺寸语义', () => {
|
||||
const c = parseEmbedConfig({ slot: 'a', size: 'md' }, { narrow: true });
|
||||
expect(c.size).toBe('sm');
|
||||
expect(c.collapsedDefault).toBe(true);
|
||||
});
|
||||
|
||||
it('非窄屏时 collapsedDefault 恒 false(收起默认由 iframe 侧按服务端设置下发)', () => {
|
||||
expect(parseEmbedConfig({ slot: 'a' }, { narrow: false }).collapsedDefault).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildEmbedUrl', () => {
|
||||
it('拼接 lang/pos/interval 查询参数', () => {
|
||||
const url = buildEmbedUrl('https://ov.example.com', 'tok', {
|
||||
lang: 'zh-CN',
|
||||
position: 'left',
|
||||
intervalMs: 7000,
|
||||
});
|
||||
expect(url).toBe('https://ov.example.com/embed/tok?lang=zh-CN&pos=left&interval=7000');
|
||||
});
|
||||
|
||||
it('slot token 编码(防路径注入)', () => {
|
||||
const url = buildEmbedUrl('https://ov.example.com', 'a/b?c=1', {
|
||||
lang: 'auto',
|
||||
position: 'right',
|
||||
intervalMs: 5000,
|
||||
});
|
||||
expect(url.startsWith('https://ov.example.com/embed/a%2Fb%3Fc%3D1?')).toBe(true);
|
||||
expect(url).toContain('lang=auto&pos=right&interval=5000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clampHeight', () => {
|
||||
it('正常值原样(取整)返回', () => {
|
||||
expect(clampHeight(200)).toBe(200);
|
||||
expect(clampHeight(200.6)).toBe(201);
|
||||
});
|
||||
|
||||
it('夹取到 [60, 640]', () => {
|
||||
expect(clampHeight(10)).toBe(60);
|
||||
expect(clampHeight(9999)).toBe(640);
|
||||
});
|
||||
|
||||
it('缺失/非数值返回 null(忽略该消息)', () => {
|
||||
expect(clampHeight(null)).toBeNull();
|
||||
expect(clampHeight(undefined)).toBeNull();
|
||||
expect(clampHeight('abc')).toBeNull();
|
||||
expect(clampHeight(NaN)).toBeNull();
|
||||
expect(clampHeight(Infinity)).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user