mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-08 19:06:15 -08:00
fix(iframe): [PKG-IFRAME-03] release failed setup and bind message peers
This commit is contained in:
1 parent
b8d03e7366
commit
b8cdafedd1
23 files changed
+7940
-32
No files matched your search
@@ -4,4 +4,4 @@
|
||||
* (c) 2017-2026 Harvey Zhao
|
||||
* Released under the MIT License.
|
||||
*/
|
||||
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.ArtplayerToolIframe=t(),define(function(){return e.ArtplayerToolIframe})):(e="undefined"!==typeof globalThis?globalThis:e||self).ArtplayerToolIframe=t()}(this,function(){"use strict";const e=new WeakMap;let t=0;class s{static get iframe(){return window.top!==window}static postMessage({type:e,data:t,id:o=0}){if(!s.iframe)throw new Error('The "ArtplayerToolIframe.postMessage" method can only be used in iframe');window.parent.postMessage({type:e,data:t,id:o},"*")}static async onMessage(e){if(!s.iframe)throw new Error('The "ArtplayerToolIframe.onMessage" method can only be used in iframe');const{type:t,data:o,id:i}=e.data;if("commit"===t)try{if(o.match(/\bresolve\((.*?)\)/)){const e=`return new Promise(function(resolve){\n${o}\n})`,t=await new Function(e)();s.postMessage({type:"response",data:t,id:i})}else{const e=new Function(o)();s.postMessage({type:"response",data:e,id:i})}}catch(r){throw s.postMessage({type:"error",data:r.message,id:i}),r}}static inject(){if(!s.iframe)throw new Error('The "ArtplayerToolIframe.inject" method can only be used in iframe');s.postMessage({type:"inject"}),window.addEventListener("message",s.onMessage)}constructor({iframe:e,url:t}){if(e instanceof HTMLIFrameElement===!1)throw new TypeError('"option.iframe" needs to be a HTMLIFrameElement');if("string"!==typeof t)throw new TypeError('"option.url" needs to be a string');this.url=t,this.$iframe=e,this.promises={},this.injected=!1,this.destroyed=!1,this.messageCallback=()=>null,this.onMessage=this.onMessage.bind(this),window.addEventListener("message",this.onMessage),this.$iframe.src=this.url}onMessage(e){if(this.destroyed)return;const{type:t,data:s,id:o}=e.data;if("inject"===t)this.injected=!0;void 0!==o&&Object.prototype.hasOwnProperty.call(this.promises,o)&&this.promises[o]&&("error"===t?this.promises[o].reject(new Error(s)):this.promises[o].resove(s),delete this.promises[o]),this.messageCallback&&this.messageCallback({type:t,data:s})}postMessage(s){return function(s,{type:o,data:i}){return new Promise((r,n)=>{if(s.destroyed)return void n(new Error("The instance has been destroyed"));const a=e.get(s)||new Set;e.set(s,a);const d=t=>!!a.delete(t)&&(void 0!==t.timer&&clearTimeout(t.timer),void 0!==t.id&&delete s.promises[t.id],a.size||e.delete(s),!0),c={callbacks:{resove(e){d(c)&&r(e)},reject(e){d(c)&&n(e)}}};a.add(c);const l=()=>{if(c.timer=void 0,a.has(c))if(s.destroyed)c.callbacks.reject(new Error("The instance has been destroyed"));else try{if(s.injected){const e=Math.max(Date.now(),t+1);t=e,c.id=e,s.promises[e]=c.callbacks,s.$iframe.contentWindow.postMessage({type:o,data:i,id:e},"*")}else c.timer=setTimeout(l,200)}catch(e){c.callbacks.reject(e)}};l()})}(this,s)}commit(e){if("function"!==typeof e)throw new TypeError('"commit.callback" needs to be a function');const t=e.toString(),s=t.substring(t.indexOf("{")+1,t.lastIndexOf("}"));return this.postMessage({type:"commit",data:s})}message(e){if("function"!==typeof e)throw new TypeError('"message.callback" needs to be a function');this.messageCallback=e}destroy(){this.destroyed=!0,window.removeEventListener("message",this.onMessage),function(t){const s=e.get(t);if(s)for(const e of[...s])e.callbacks.reject(new Error("The instance has been destroyed"))}(this)}}return s});
|
||||
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.ArtplayerToolIframe=t(),define(function(){return e.ArtplayerToolIframe})):(e="undefined"!==typeof globalThis?globalThis:e||self).ArtplayerToolIframe=t()}(this,function(){"use strict";const e=new WeakMap;let t=0;const s=new WeakMap;function o(t){const o=s.get(t);s.delete(t);try{o?.()}finally{!function(t){const s=e.get(t);if(s)for(const e of[...s])e.callbacks.reject(new Error("The instance has been destroyed"))}(t)}}function r(e,t){if(void 0!==e.source&&null!==e.source&&e.source!==t)return!1;const s=e.data;return"object"===typeof s&&null!==s&&"type"in s&&"string"===typeof s.type}class n{static get iframe(){return window.top!==window}static postMessage({type:e,data:t,id:s=0}){if(!n.iframe)throw new Error('The "ArtplayerToolIframe.postMessage" method can only be used in iframe');window.parent.postMessage({type:e,data:t,id:s},"*")}static async onMessage(e){if(!n.iframe)throw new Error('The "ArtplayerToolIframe.onMessage" method can only be used in iframe');if(!r(e,window.parent))return;const{type:t,data:s,id:o}=e.data;if("commit"===t)try{if(s.match(/\bresolve\((.*?)\)/)){const e=`return new Promise(function(resolve){\n${s}\n})`,t=await new Function(e)();n.postMessage({type:"response",data:t,id:o})}else{const e=new Function(s)();n.postMessage({type:"response",data:e,id:o})}}catch(i){throw n.postMessage({type:"error",data:i.message,id:o}),i}}static inject(){if(!n.iframe)throw new Error('The "ArtplayerToolIframe.inject" method can only be used in iframe');n.postMessage({type:"inject"}),window.addEventListener("message",n.onMessage)}constructor({iframe:e,url:t}){if(e instanceof HTMLIFrameElement===!1)throw new TypeError('"option.iframe" needs to be a HTMLIFrameElement');if("string"!==typeof t)throw new TypeError('"option.url" needs to be a string');this.url=t,this.$iframe=e,this.promises={},this.injected=!1,this.destroyed=!1,this.messageCallback=()=>null,this.onMessage=this.onMessage.bind(this);try{!function(e){const t=window,o=e.onMessage;s.set(e,()=>t.removeEventListener("message",o)),t.addEventListener("message",o),e.$iframe.src=e.url}(this)}catch(r){this.destroyed=!0;try{o(this)}catch{}throw r}}onMessage(e){if(this.destroyed||!r(e,this.$iframe.contentWindow))return;const{type:t,data:s,id:o}=e.data;if("inject"===t)this.injected=!0;void 0!==o&&Object.prototype.hasOwnProperty.call(this.promises,o)&&this.promises[o]&&("error"===t?this.promises[o].reject(new Error(s)):this.promises[o].resove(s),delete this.promises[o]),this.messageCallback&&this.messageCallback({type:t,data:s})}postMessage(s){return function(s,{type:o,data:r}){return new Promise((n,i)=>{if(s.destroyed)return void i(new Error("The instance has been destroyed"));const a=e.get(s)||new Set;e.set(s,a);const c=t=>!!a.delete(t)&&(void 0!==t.timer&&clearTimeout(t.timer),void 0!==t.id&&delete s.promises[t.id],a.size||e.delete(s),!0),d={callbacks:{resove(e){c(d)&&n(e)},reject(e){c(d)&&i(e)}}};a.add(d);const l=()=>{if(d.timer=void 0,a.has(d))if(s.destroyed)d.callbacks.reject(new Error("The instance has been destroyed"));else try{if(s.injected){const e=Math.max(Date.now(),t+1);t=e,d.id=e,s.promises[e]=d.callbacks,s.$iframe.contentWindow.postMessage({type:o,data:r,id:e},"*")}else d.timer=setTimeout(l,200)}catch(e){d.callbacks.reject(e)}};l()})}(this,s)}commit(e){if("function"!==typeof e)throw new TypeError('"commit.callback" needs to be a function');const t=e.toString(),s=t.substring(t.indexOf("{")+1,t.lastIndexOf("}"));return this.postMessage({type:"commit",data:s})}message(e){if("function"!==typeof e)throw new TypeError('"message.callback" needs to be a function');this.messageCallback=e}destroy(){this.destroyed=!0,o(this)}}return n});
|
||||
@@ -4,4 +4,4 @@
|
||||
* (c) 2017-2026 Harvey Zhao
|
||||
* Released under the MIT License.
|
||||
*/
|
||||
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.ArtplayerToolIframe=t(),define(function(){return e.ArtplayerToolIframe})):(e="undefined"!==typeof globalThis?globalThis:e||self).ArtplayerToolIframe=t()}(this,function(){"use strict";const e=new WeakMap;let t=0;class s{static get iframe(){return window.top!==window}static postMessage({type:e,data:t,id:o=0}){if(!s.iframe)throw new Error('The "ArtplayerToolIframe.postMessage" method can only be used in iframe');window.parent.postMessage({type:e,data:t,id:o},"*")}static onMessage(e){return t=this,o=null,r=function*(){if(!s.iframe)throw new Error('The "ArtplayerToolIframe.onMessage" method can only be used in iframe');const{type:t,data:o,id:r}=e.data;if("commit"===t)try{if(o.match(/\bresolve\((.*?)\)/)){const e=`return new Promise(function(resolve){\n${o}\n})`,t=yield new Function(e)();s.postMessage({type:"response",data:t,id:r})}else{const e=new Function(o)();s.postMessage({type:"response",data:e,id:r})}}catch(i){throw s.postMessage({type:"error",data:i.message,id:r}),i}},new Promise((e,s)=>{var i=e=>{try{a(r.next(e))}catch(t){s(t)}},n=e=>{try{a(r.throw(e))}catch(t){s(t)}},a=t=>t.done?e(t.value):Promise.resolve(t.value).then(i,n);a((r=r.apply(t,o)).next())});var t,o,r}static inject(){if(!s.iframe)throw new Error('The "ArtplayerToolIframe.inject" method can only be used in iframe');s.postMessage({type:"inject"}),window.addEventListener("message",s.onMessage)}constructor({iframe:e,url:t}){if(e instanceof HTMLIFrameElement===!1)throw new TypeError('"option.iframe" needs to be a HTMLIFrameElement');if("string"!==typeof t)throw new TypeError('"option.url" needs to be a string');this.url=t,this.$iframe=e,this.promises={},this.injected=!1,this.destroyed=!1,this.messageCallback=()=>null,this.onMessage=this.onMessage.bind(this),window.addEventListener("message",this.onMessage),this.$iframe.src=this.url}onMessage(e){if(this.destroyed)return;const{type:t,data:s,id:o}=e.data;if("inject"===t)this.injected=!0;void 0!==o&&Object.prototype.hasOwnProperty.call(this.promises,o)&&this.promises[o]&&("error"===t?this.promises[o].reject(new Error(s)):this.promises[o].resove(s),delete this.promises[o]),this.messageCallback&&this.messageCallback({type:t,data:s})}postMessage(s){return function(s,{type:o,data:r}){return new Promise((i,n)=>{if(s.destroyed)return void n(new Error("The instance has been destroyed"));const a=e.get(s)||new Set;e.set(s,a);const d=t=>!!a.delete(t)&&(void 0!==t.timer&&clearTimeout(t.timer),void 0!==t.id&&delete s.promises[t.id],a.size||e.delete(s),!0),c={callbacks:{resove(e){d(c)&&i(e)},reject(e){d(c)&&n(e)}}};a.add(c);const l=()=>{if(c.timer=void 0,a.has(c))if(s.destroyed)c.callbacks.reject(new Error("The instance has been destroyed"));else try{if(s.injected){const e=Math.max(Date.now(),t+1);t=e,c.id=e,s.promises[e]=c.callbacks,s.$iframe.contentWindow.postMessage({type:o,data:r,id:e},"*")}else c.timer=setTimeout(l,200)}catch(e){c.callbacks.reject(e)}};l()})}(this,s)}commit(e){if("function"!==typeof e)throw new TypeError('"commit.callback" needs to be a function');const t=e.toString(),s=t.substring(t.indexOf("{")+1,t.lastIndexOf("}"));return this.postMessage({type:"commit",data:s})}message(e){if("function"!==typeof e)throw new TypeError('"message.callback" needs to be a function');this.messageCallback=e}destroy(){this.destroyed=!0,window.removeEventListener("message",this.onMessage),function(t){const s=e.get(t);if(s)for(const e of[...s])e.callbacks.reject(new Error("The instance has been destroyed"))}(this)}}return s});
|
||||
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.ArtplayerToolIframe=t(),define(function(){return e.ArtplayerToolIframe})):(e="undefined"!==typeof globalThis?globalThis:e||self).ArtplayerToolIframe=t()}(this,function(){"use strict";const e=new WeakMap;let t=0;const s=new WeakMap;function o(t){const o=s.get(t);s.delete(t);try{null==o||o()}finally{!function(t){const s=e.get(t);if(s)for(const e of[...s])e.callbacks.reject(new Error("The instance has been destroyed"))}(t)}}function r(e,t){if(void 0!==e.source&&null!==e.source&&e.source!==t)return!1;const s=e.data;return"object"===typeof s&&null!==s&&"type"in s&&"string"===typeof s.type}class n{static get iframe(){return window.top!==window}static postMessage({type:e,data:t,id:s=0}){if(!n.iframe)throw new Error('The "ArtplayerToolIframe.postMessage" method can only be used in iframe');window.parent.postMessage({type:e,data:t,id:s},"*")}static onMessage(e){return t=this,s=null,o=function*(){if(!n.iframe)throw new Error('The "ArtplayerToolIframe.onMessage" method can only be used in iframe');if(!r(e,window.parent))return;const{type:t,data:s,id:o}=e.data;if("commit"===t)try{if(s.match(/\bresolve\((.*?)\)/)){const e=`return new Promise(function(resolve){\n${s}\n})`,t=yield new Function(e)();n.postMessage({type:"response",data:t,id:o})}else{const e=new Function(s)();n.postMessage({type:"response",data:e,id:o})}}catch(i){throw n.postMessage({type:"error",data:i.message,id:o}),i}},new Promise((e,r)=>{var n=e=>{try{a(o.next(e))}catch(t){r(t)}},i=e=>{try{a(o.throw(e))}catch(t){r(t)}},a=t=>t.done?e(t.value):Promise.resolve(t.value).then(n,i);a((o=o.apply(t,s)).next())});var t,s,o}static inject(){if(!n.iframe)throw new Error('The "ArtplayerToolIframe.inject" method can only be used in iframe');n.postMessage({type:"inject"}),window.addEventListener("message",n.onMessage)}constructor({iframe:e,url:t}){if(e instanceof HTMLIFrameElement===!1)throw new TypeError('"option.iframe" needs to be a HTMLIFrameElement');if("string"!==typeof t)throw new TypeError('"option.url" needs to be a string');this.url=t,this.$iframe=e,this.promises={},this.injected=!1,this.destroyed=!1,this.messageCallback=()=>null,this.onMessage=this.onMessage.bind(this);try{!function(e){const t=window,o=e.onMessage;s.set(e,()=>t.removeEventListener("message",o)),t.addEventListener("message",o),e.$iframe.src=e.url}(this)}catch(r){this.destroyed=!0;try{o(this)}catch(n){}throw r}}onMessage(e){if(this.destroyed||!r(e,this.$iframe.contentWindow))return;const{type:t,data:s,id:o}=e.data;if("inject"===t)this.injected=!0;void 0!==o&&Object.prototype.hasOwnProperty.call(this.promises,o)&&this.promises[o]&&("error"===t?this.promises[o].reject(new Error(s)):this.promises[o].resove(s),delete this.promises[o]),this.messageCallback&&this.messageCallback({type:t,data:s})}postMessage(s){return function(s,{type:o,data:r}){return new Promise((n,i)=>{if(s.destroyed)return void i(new Error("The instance has been destroyed"));const a=e.get(s)||new Set;e.set(s,a);const c=t=>!!a.delete(t)&&(void 0!==t.timer&&clearTimeout(t.timer),void 0!==t.id&&delete s.promises[t.id],a.size||e.delete(s),!0),d={callbacks:{resove(e){c(d)&&n(e)},reject(e){c(d)&&i(e)}}};a.add(d);const l=()=>{if(d.timer=void 0,a.has(d))if(s.destroyed)d.callbacks.reject(new Error("The instance has been destroyed"));else try{if(s.injected){const e=Math.max(Date.now(),t+1);t=e,d.id=e,s.promises[e]=d.callbacks,s.$iframe.contentWindow.postMessage({type:o,data:r,id:e},"*")}else d.timer=setTimeout(l,200)}catch(e){d.callbacks.reject(e)}};l()})}(this,s)}commit(e){if("function"!==typeof e)throw new TypeError('"commit.callback" needs to be a function');const t=e.toString(),s=t.substring(t.indexOf("{")+1,t.lastIndexOf("}"));return this.postMessage({type:"commit",data:s})}message(e){if("function"!==typeof e)throw new TypeError('"message.callback" needs to be a function');this.messageCallback=e}destroy(){this.destroyed=!0,o(this)}}return n});
|
||||
@@ -70,6 +70,29 @@ function cancelRequests(host) {
|
||||
for (const request of [...pending])
|
||||
request.callbacks.reject(new Error("The instance has been destroyed"));
|
||||
}
|
||||
const connections = /* @__PURE__ */ new WeakMap();
|
||||
function connect(host) {
|
||||
const owner = window;
|
||||
const receiver = host.onMessage;
|
||||
connections.set(host, () => owner.removeEventListener("message", receiver));
|
||||
owner.addEventListener("message", receiver);
|
||||
host.$iframe.src = host.url;
|
||||
}
|
||||
function releaseConnection(host) {
|
||||
const disconnect = connections.get(host);
|
||||
connections.delete(host);
|
||||
try {
|
||||
disconnect?.();
|
||||
} finally {
|
||||
cancelRequests(host);
|
||||
}
|
||||
}
|
||||
function acceptsMessage(event, peer) {
|
||||
if (event.source !== void 0 && event.source !== null && event.source !== peer)
|
||||
return false;
|
||||
const data = event.data;
|
||||
return typeof data === "object" && data !== null && "type" in data && typeof data.type === "string";
|
||||
}
|
||||
class ArtplayerToolIframe {
|
||||
static get iframe() {
|
||||
return window.top !== window;
|
||||
@@ -91,6 +114,8 @@ class ArtplayerToolIframe {
|
||||
if (!ArtplayerToolIframe.iframe) {
|
||||
throw new Error('The "ArtplayerToolIframe.onMessage" method can only be used in iframe');
|
||||
}
|
||||
if (!acceptsMessage(event, window.parent))
|
||||
return;
|
||||
const { type, data, id } = event.data;
|
||||
switch (type) {
|
||||
case "commit":
|
||||
@@ -133,11 +158,19 @@ ${data}
|
||||
this.destroyed = false;
|
||||
this.messageCallback = () => null;
|
||||
this.onMessage = this.onMessage.bind(this);
|
||||
window.addEventListener("message", this.onMessage);
|
||||
this.$iframe.src = this.url;
|
||||
try {
|
||||
connect(this);
|
||||
} catch (error) {
|
||||
this.destroyed = true;
|
||||
try {
|
||||
releaseConnection(this);
|
||||
} catch {
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
onMessage(event) {
|
||||
if (this.destroyed)
|
||||
if (this.destroyed || !acceptsMessage(event, this.$iframe.contentWindow))
|
||||
return;
|
||||
const { type, data, id } = event.data;
|
||||
switch (type) {
|
||||
@@ -176,8 +209,7 @@ ${data}
|
||||
}
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
window.removeEventListener("message", this.onMessage);
|
||||
cancelRequests(this);
|
||||
releaseConnection(this);
|
||||
}
|
||||
}
|
||||
export {
|
||||
|
||||
+2
-2
@@ -51,7 +51,7 @@
|
||||
"ci:build": "yarn build:types && yarn build all && yarn build:i18n && yarn build:ts && yarn build:docs && yarn test:imports",
|
||||
"test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js",
|
||||
"typecheck": "node scripts/typecheck.mjs",
|
||||
"test:unit": "node --test test/iframe-lifecycle.test.js test/iframe.test.js test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-coordination.test.js test/mediabunny-video.test.js test/mediabunny-audio.test.js test/mediabunny-hls.test.js test/mediabunny-entry.test.js test/mediabunny-capability.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js test/dpip.test.js test/dpip-lifecycle.test.js test/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js",
|
||||
"test:unit": "node --test test/iframe-boundaries.test.js test/iframe-lifecycle.test.js test/iframe.test.js test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-coordination.test.js test/mediabunny-video.test.js test/mediabunny-audio.test.js test/mediabunny-hls.test.js test/mediabunny-entry.test.js test/mediabunny-capability.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js test/dpip.test.js test/dpip-lifecycle.test.js test/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js",
|
||||
"test:coverage": "node --test test/coverage.test.js && node scripts/coverage.mjs",
|
||||
"test": "yarn test:node && yarn test:baseline",
|
||||
"test:browser": "playwright test",
|
||||
@@ -80,7 +80,7 @@
|
||||
"test:mediabunny-types-package": "node refactor/scripts/mb-package-types.mjs",
|
||||
"test:mediabunny-soak": "playwright test --config playwright.soak.config.js",
|
||||
"generate:media:hls": "node scripts/generate-hls-fixture.mjs",
|
||||
"test:iframe": "node --test test/iframe.test.js test/iframe-lifecycle.test.js refactor/scripts/iframe-contract.test.mjs"
|
||||
"test:iframe": "node --test test/iframe-boundaries.test.js test/iframe.test.js test/iframe-lifecycle.test.js refactor/scripts/iframe-contract.test.mjs"
|
||||
},
|
||||
"browserslist": "last 1 Chrome version",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -5,6 +5,9 @@ message listener, child injection/execution protocol and public callbacks.
|
||||
`src/requests.ts` owns request IDs, pending promises, injection polling and
|
||||
settlement/cancellation. Its host interface uses only the iframe, registry and
|
||||
two lifecycle flags; it does not depend on ArtPlayer or import the entry class.
|
||||
`src/connection.ts` owns listener acquisition/release and ensures request
|
||||
cancellation runs even if listener removal throws. `src/protocol.ts` checks the
|
||||
selected window peer and packet shape before either public receiver processes it.
|
||||
|
||||
The entry initializes the same seven enumerable, writable fields in the same
|
||||
order. Internal request ownership lives in a module WeakMap, keeping instance
|
||||
@@ -30,6 +33,10 @@ including failures reached from polling. `destroy()` first marks the instance
|
||||
destroyed, removes its listener, then immediately rejects both sent and waiting
|
||||
requests with `The instance has been destroyed`. Saved listener/timer callbacks
|
||||
become inert. Repeated destroy is harmless. There is no implicit request timeout.
|
||||
If constructor setup fails, it marks the partial instance destroyed, releases
|
||||
the acquired listener and cancels reentrant requests, preserving the original
|
||||
setup error even if cleanup also fails. The listener receiver and owning window
|
||||
are captured on acquisition; connection ownership is removed before releasing it.
|
||||
|
||||
These are deliberate defect corrections: callers holding a promise during
|
||||
destroy must handle rejection; previously sent requests remained pending forever.
|
||||
@@ -46,10 +53,24 @@ the async handler; resolver commits await the result. Execution errors still
|
||||
send an error packet and reject the handler. Expression arrow functions, closure
|
||||
capture and CSP restrictions have not been replaced with a new RPC protocol.
|
||||
|
||||
This checkpoint does not fix navigation/reinjection, malformed message handling
|
||||
or source/origin validation. The executable commit protocol is not a sandbox;
|
||||
the current listener still accepts messages from unrelated windows. IFRAME-LIFE-01
|
||||
and IFRAME-TRUST-01 remain open. Continue in PKG-IFRAME-03/05 before release.
|
||||
Both receivers ignore malformed payloads (a non-null object with a string `type`
|
||||
is required, including an empty string). The parent accepts native messages only
|
||||
from the configured iframe's contentWindow; the child accepts them only from
|
||||
window.parent. Public direct onMessage calls with a missing/null source remain
|
||||
available to local callers. Same-page script or privileged injection is not
|
||||
isolated by this check. Generic response types and commit payload error handling
|
||||
are unchanged after the peer/envelope check.
|
||||
|
||||
The initial URL's origin is deliberately not pinned: redirects and sandboxed
|
||||
opaque-origin children still communicate with their selected parent. The peer's
|
||||
content and the embedding parent must be trusted; executable commit is not a
|
||||
sandbox or a parent-origin allowlist. Upgrading one side does not secure the
|
||||
unchanged historical receiver on the other side. See the independent decision in
|
||||
[iframe-message-boundary.md](../../refactor/iframe-message-boundary.md).
|
||||
|
||||
Navigation/reinjection remains unfinished. A child can inject before iframe load;
|
||||
blindly resetting state on load would lose a valid handshake. IFRAME-LIFE-01 and
|
||||
IFRAME-TRUST-01 remain open for complete lifecycle/integration and release review.
|
||||
|
||||
Public declarations remain in `types/artplayer-tool-iframe.d.ts`; source typing
|
||||
does not yet establish historical declaration/entry compatibility. PKG-IFRAME-04
|
||||
@@ -67,6 +88,10 @@ publication is implied by this source migration.
|
||||
generated docs copies. Never hand-edit those files.
|
||||
- `yarn test:browser test/browser/iframe.spec.js`: real same/cross-origin windows
|
||||
in Chromium, Firefox and WebKit. This tool-only fixture creates no player.
|
||||
- `yarn test:browser test/browser/iframe-boundaries.spec.js`: native peer rejection,
|
||||
malformed packets, actual HTTP redirects, opaque sandbox and normal new/old
|
||||
parent-child wire combinations. `ARTPLAYER_IFRAME_BOUNDARIES_ONLY=1` omits the
|
||||
mixed-version controls when reproducing a candidate boundary failure.
|
||||
- Set `ARTPLAYER_IFRAME_LIFECYCLE_ONLY=1` for candidate lifecycle browser rows;
|
||||
`ARTPLAYER_IFRAME_ARTIFACT` selects an actual built file. For an unchanged
|
||||
candidate test against the old workspace, set `ARTPLAYER_IFRAME_BASELINE=1`.
|
||||
|
||||
@@ -4,4 +4,4 @@
|
||||
* (c) 2017-2026 Harvey Zhao
|
||||
* Released under the MIT License.
|
||||
*/
|
||||
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.ArtplayerToolIframe=t(),define(function(){return e.ArtplayerToolIframe})):(e="undefined"!==typeof globalThis?globalThis:e||self).ArtplayerToolIframe=t()}(this,function(){"use strict";const e=new WeakMap;let t=0;class s{static get iframe(){return window.top!==window}static postMessage({type:e,data:t,id:o=0}){if(!s.iframe)throw new Error('The "ArtplayerToolIframe.postMessage" method can only be used in iframe');window.parent.postMessage({type:e,data:t,id:o},"*")}static async onMessage(e){if(!s.iframe)throw new Error('The "ArtplayerToolIframe.onMessage" method can only be used in iframe');const{type:t,data:o,id:i}=e.data;if("commit"===t)try{if(o.match(/\bresolve\((.*?)\)/)){const e=`return new Promise(function(resolve){\n${o}\n})`,t=await new Function(e)();s.postMessage({type:"response",data:t,id:i})}else{const e=new Function(o)();s.postMessage({type:"response",data:e,id:i})}}catch(r){throw s.postMessage({type:"error",data:r.message,id:i}),r}}static inject(){if(!s.iframe)throw new Error('The "ArtplayerToolIframe.inject" method can only be used in iframe');s.postMessage({type:"inject"}),window.addEventListener("message",s.onMessage)}constructor({iframe:e,url:t}){if(e instanceof HTMLIFrameElement===!1)throw new TypeError('"option.iframe" needs to be a HTMLIFrameElement');if("string"!==typeof t)throw new TypeError('"option.url" needs to be a string');this.url=t,this.$iframe=e,this.promises={},this.injected=!1,this.destroyed=!1,this.messageCallback=()=>null,this.onMessage=this.onMessage.bind(this),window.addEventListener("message",this.onMessage),this.$iframe.src=this.url}onMessage(e){if(this.destroyed)return;const{type:t,data:s,id:o}=e.data;if("inject"===t)this.injected=!0;void 0!==o&&Object.prototype.hasOwnProperty.call(this.promises,o)&&this.promises[o]&&("error"===t?this.promises[o].reject(new Error(s)):this.promises[o].resove(s),delete this.promises[o]),this.messageCallback&&this.messageCallback({type:t,data:s})}postMessage(s){return function(s,{type:o,data:i}){return new Promise((r,n)=>{if(s.destroyed)return void n(new Error("The instance has been destroyed"));const a=e.get(s)||new Set;e.set(s,a);const d=t=>!!a.delete(t)&&(void 0!==t.timer&&clearTimeout(t.timer),void 0!==t.id&&delete s.promises[t.id],a.size||e.delete(s),!0),c={callbacks:{resove(e){d(c)&&r(e)},reject(e){d(c)&&n(e)}}};a.add(c);const l=()=>{if(c.timer=void 0,a.has(c))if(s.destroyed)c.callbacks.reject(new Error("The instance has been destroyed"));else try{if(s.injected){const e=Math.max(Date.now(),t+1);t=e,c.id=e,s.promises[e]=c.callbacks,s.$iframe.contentWindow.postMessage({type:o,data:i,id:e},"*")}else c.timer=setTimeout(l,200)}catch(e){c.callbacks.reject(e)}};l()})}(this,s)}commit(e){if("function"!==typeof e)throw new TypeError('"commit.callback" needs to be a function');const t=e.toString(),s=t.substring(t.indexOf("{")+1,t.lastIndexOf("}"));return this.postMessage({type:"commit",data:s})}message(e){if("function"!==typeof e)throw new TypeError('"message.callback" needs to be a function');this.messageCallback=e}destroy(){this.destroyed=!0,window.removeEventListener("message",this.onMessage),function(t){const s=e.get(t);if(s)for(const e of[...s])e.callbacks.reject(new Error("The instance has been destroyed"))}(this)}}return s});
|
||||
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.ArtplayerToolIframe=t(),define(function(){return e.ArtplayerToolIframe})):(e="undefined"!==typeof globalThis?globalThis:e||self).ArtplayerToolIframe=t()}(this,function(){"use strict";const e=new WeakMap;let t=0;const s=new WeakMap;function o(t){const o=s.get(t);s.delete(t);try{o?.()}finally{!function(t){const s=e.get(t);if(s)for(const e of[...s])e.callbacks.reject(new Error("The instance has been destroyed"))}(t)}}function r(e,t){if(void 0!==e.source&&null!==e.source&&e.source!==t)return!1;const s=e.data;return"object"===typeof s&&null!==s&&"type"in s&&"string"===typeof s.type}class n{static get iframe(){return window.top!==window}static postMessage({type:e,data:t,id:s=0}){if(!n.iframe)throw new Error('The "ArtplayerToolIframe.postMessage" method can only be used in iframe');window.parent.postMessage({type:e,data:t,id:s},"*")}static async onMessage(e){if(!n.iframe)throw new Error('The "ArtplayerToolIframe.onMessage" method can only be used in iframe');if(!r(e,window.parent))return;const{type:t,data:s,id:o}=e.data;if("commit"===t)try{if(s.match(/\bresolve\((.*?)\)/)){const e=`return new Promise(function(resolve){\n${s}\n})`,t=await new Function(e)();n.postMessage({type:"response",data:t,id:o})}else{const e=new Function(s)();n.postMessage({type:"response",data:e,id:o})}}catch(i){throw n.postMessage({type:"error",data:i.message,id:o}),i}}static inject(){if(!n.iframe)throw new Error('The "ArtplayerToolIframe.inject" method can only be used in iframe');n.postMessage({type:"inject"}),window.addEventListener("message",n.onMessage)}constructor({iframe:e,url:t}){if(e instanceof HTMLIFrameElement===!1)throw new TypeError('"option.iframe" needs to be a HTMLIFrameElement');if("string"!==typeof t)throw new TypeError('"option.url" needs to be a string');this.url=t,this.$iframe=e,this.promises={},this.injected=!1,this.destroyed=!1,this.messageCallback=()=>null,this.onMessage=this.onMessage.bind(this);try{!function(e){const t=window,o=e.onMessage;s.set(e,()=>t.removeEventListener("message",o)),t.addEventListener("message",o),e.$iframe.src=e.url}(this)}catch(r){this.destroyed=!0;try{o(this)}catch{}throw r}}onMessage(e){if(this.destroyed||!r(e,this.$iframe.contentWindow))return;const{type:t,data:s,id:o}=e.data;if("inject"===t)this.injected=!0;void 0!==o&&Object.prototype.hasOwnProperty.call(this.promises,o)&&this.promises[o]&&("error"===t?this.promises[o].reject(new Error(s)):this.promises[o].resove(s),delete this.promises[o]),this.messageCallback&&this.messageCallback({type:t,data:s})}postMessage(s){return function(s,{type:o,data:r}){return new Promise((n,i)=>{if(s.destroyed)return void i(new Error("The instance has been destroyed"));const a=e.get(s)||new Set;e.set(s,a);const c=t=>!!a.delete(t)&&(void 0!==t.timer&&clearTimeout(t.timer),void 0!==t.id&&delete s.promises[t.id],a.size||e.delete(s),!0),d={callbacks:{resove(e){c(d)&&n(e)},reject(e){c(d)&&i(e)}}};a.add(d);const l=()=>{if(d.timer=void 0,a.has(d))if(s.destroyed)d.callbacks.reject(new Error("The instance has been destroyed"));else try{if(s.injected){const e=Math.max(Date.now(),t+1);t=e,d.id=e,s.promises[e]=d.callbacks,s.$iframe.contentWindow.postMessage({type:o,data:r,id:e},"*")}else d.timer=setTimeout(l,200)}catch(e){d.callbacks.reject(e)}};l()})}(this,s)}commit(e){if("function"!==typeof e)throw new TypeError('"commit.callback" needs to be a function');const t=e.toString(),s=t.substring(t.indexOf("{")+1,t.lastIndexOf("}"));return this.postMessage({type:"commit",data:s})}message(e){if("function"!==typeof e)throw new TypeError('"message.callback" needs to be a function');this.messageCallback=e}destroy(){this.destroyed=!0,o(this)}}return n});
|
||||
@@ -4,4 +4,4 @@
|
||||
* (c) 2017-2026 Harvey Zhao
|
||||
* Released under the MIT License.
|
||||
*/
|
||||
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.ArtplayerToolIframe=t(),define(function(){return e.ArtplayerToolIframe})):(e="undefined"!==typeof globalThis?globalThis:e||self).ArtplayerToolIframe=t()}(this,function(){"use strict";const e=new WeakMap;let t=0;class s{static get iframe(){return window.top!==window}static postMessage({type:e,data:t,id:o=0}){if(!s.iframe)throw new Error('The "ArtplayerToolIframe.postMessage" method can only be used in iframe');window.parent.postMessage({type:e,data:t,id:o},"*")}static onMessage(e){return t=this,o=null,r=function*(){if(!s.iframe)throw new Error('The "ArtplayerToolIframe.onMessage" method can only be used in iframe');const{type:t,data:o,id:r}=e.data;if("commit"===t)try{if(o.match(/\bresolve\((.*?)\)/)){const e=`return new Promise(function(resolve){\n${o}\n})`,t=yield new Function(e)();s.postMessage({type:"response",data:t,id:r})}else{const e=new Function(o)();s.postMessage({type:"response",data:e,id:r})}}catch(i){throw s.postMessage({type:"error",data:i.message,id:r}),i}},new Promise((e,s)=>{var i=e=>{try{a(r.next(e))}catch(t){s(t)}},n=e=>{try{a(r.throw(e))}catch(t){s(t)}},a=t=>t.done?e(t.value):Promise.resolve(t.value).then(i,n);a((r=r.apply(t,o)).next())});var t,o,r}static inject(){if(!s.iframe)throw new Error('The "ArtplayerToolIframe.inject" method can only be used in iframe');s.postMessage({type:"inject"}),window.addEventListener("message",s.onMessage)}constructor({iframe:e,url:t}){if(e instanceof HTMLIFrameElement===!1)throw new TypeError('"option.iframe" needs to be a HTMLIFrameElement');if("string"!==typeof t)throw new TypeError('"option.url" needs to be a string');this.url=t,this.$iframe=e,this.promises={},this.injected=!1,this.destroyed=!1,this.messageCallback=()=>null,this.onMessage=this.onMessage.bind(this),window.addEventListener("message",this.onMessage),this.$iframe.src=this.url}onMessage(e){if(this.destroyed)return;const{type:t,data:s,id:o}=e.data;if("inject"===t)this.injected=!0;void 0!==o&&Object.prototype.hasOwnProperty.call(this.promises,o)&&this.promises[o]&&("error"===t?this.promises[o].reject(new Error(s)):this.promises[o].resove(s),delete this.promises[o]),this.messageCallback&&this.messageCallback({type:t,data:s})}postMessage(s){return function(s,{type:o,data:r}){return new Promise((i,n)=>{if(s.destroyed)return void n(new Error("The instance has been destroyed"));const a=e.get(s)||new Set;e.set(s,a);const d=t=>!!a.delete(t)&&(void 0!==t.timer&&clearTimeout(t.timer),void 0!==t.id&&delete s.promises[t.id],a.size||e.delete(s),!0),c={callbacks:{resove(e){d(c)&&i(e)},reject(e){d(c)&&n(e)}}};a.add(c);const l=()=>{if(c.timer=void 0,a.has(c))if(s.destroyed)c.callbacks.reject(new Error("The instance has been destroyed"));else try{if(s.injected){const e=Math.max(Date.now(),t+1);t=e,c.id=e,s.promises[e]=c.callbacks,s.$iframe.contentWindow.postMessage({type:o,data:r,id:e},"*")}else c.timer=setTimeout(l,200)}catch(e){c.callbacks.reject(e)}};l()})}(this,s)}commit(e){if("function"!==typeof e)throw new TypeError('"commit.callback" needs to be a function');const t=e.toString(),s=t.substring(t.indexOf("{")+1,t.lastIndexOf("}"));return this.postMessage({type:"commit",data:s})}message(e){if("function"!==typeof e)throw new TypeError('"message.callback" needs to be a function');this.messageCallback=e}destroy(){this.destroyed=!0,window.removeEventListener("message",this.onMessage),function(t){const s=e.get(t);if(s)for(const e of[...s])e.callbacks.reject(new Error("The instance has been destroyed"))}(this)}}return s});
|
||||
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.ArtplayerToolIframe=t(),define(function(){return e.ArtplayerToolIframe})):(e="undefined"!==typeof globalThis?globalThis:e||self).ArtplayerToolIframe=t()}(this,function(){"use strict";const e=new WeakMap;let t=0;const s=new WeakMap;function o(t){const o=s.get(t);s.delete(t);try{null==o||o()}finally{!function(t){const s=e.get(t);if(s)for(const e of[...s])e.callbacks.reject(new Error("The instance has been destroyed"))}(t)}}function r(e,t){if(void 0!==e.source&&null!==e.source&&e.source!==t)return!1;const s=e.data;return"object"===typeof s&&null!==s&&"type"in s&&"string"===typeof s.type}class n{static get iframe(){return window.top!==window}static postMessage({type:e,data:t,id:s=0}){if(!n.iframe)throw new Error('The "ArtplayerToolIframe.postMessage" method can only be used in iframe');window.parent.postMessage({type:e,data:t,id:s},"*")}static onMessage(e){return t=this,s=null,o=function*(){if(!n.iframe)throw new Error('The "ArtplayerToolIframe.onMessage" method can only be used in iframe');if(!r(e,window.parent))return;const{type:t,data:s,id:o}=e.data;if("commit"===t)try{if(s.match(/\bresolve\((.*?)\)/)){const e=`return new Promise(function(resolve){\n${s}\n})`,t=yield new Function(e)();n.postMessage({type:"response",data:t,id:o})}else{const e=new Function(s)();n.postMessage({type:"response",data:e,id:o})}}catch(i){throw n.postMessage({type:"error",data:i.message,id:o}),i}},new Promise((e,r)=>{var n=e=>{try{a(o.next(e))}catch(t){r(t)}},i=e=>{try{a(o.throw(e))}catch(t){r(t)}},a=t=>t.done?e(t.value):Promise.resolve(t.value).then(n,i);a((o=o.apply(t,s)).next())});var t,s,o}static inject(){if(!n.iframe)throw new Error('The "ArtplayerToolIframe.inject" method can only be used in iframe');n.postMessage({type:"inject"}),window.addEventListener("message",n.onMessage)}constructor({iframe:e,url:t}){if(e instanceof HTMLIFrameElement===!1)throw new TypeError('"option.iframe" needs to be a HTMLIFrameElement');if("string"!==typeof t)throw new TypeError('"option.url" needs to be a string');this.url=t,this.$iframe=e,this.promises={},this.injected=!1,this.destroyed=!1,this.messageCallback=()=>null,this.onMessage=this.onMessage.bind(this);try{!function(e){const t=window,o=e.onMessage;s.set(e,()=>t.removeEventListener("message",o)),t.addEventListener("message",o),e.$iframe.src=e.url}(this)}catch(r){this.destroyed=!0;try{o(this)}catch(n){}throw r}}onMessage(e){if(this.destroyed||!r(e,this.$iframe.contentWindow))return;const{type:t,data:s,id:o}=e.data;if("inject"===t)this.injected=!0;void 0!==o&&Object.prototype.hasOwnProperty.call(this.promises,o)&&this.promises[o]&&("error"===t?this.promises[o].reject(new Error(s)):this.promises[o].resove(s),delete this.promises[o]),this.messageCallback&&this.messageCallback({type:t,data:s})}postMessage(s){return function(s,{type:o,data:r}){return new Promise((n,i)=>{if(s.destroyed)return void i(new Error("The instance has been destroyed"));const a=e.get(s)||new Set;e.set(s,a);const c=t=>!!a.delete(t)&&(void 0!==t.timer&&clearTimeout(t.timer),void 0!==t.id&&delete s.promises[t.id],a.size||e.delete(s),!0),d={callbacks:{resove(e){c(d)&&n(e)},reject(e){c(d)&&i(e)}}};a.add(d);const l=()=>{if(d.timer=void 0,a.has(d))if(s.destroyed)d.callbacks.reject(new Error("The instance has been destroyed"));else try{if(s.injected){const e=Math.max(Date.now(),t+1);t=e,d.id=e,s.promises[e]=d.callbacks,s.$iframe.contentWindow.postMessage({type:o,data:r,id:e},"*")}else d.timer=setTimeout(l,200)}catch(e){d.callbacks.reject(e)}};l()})}(this,s)}commit(e){if("function"!==typeof e)throw new TypeError('"commit.callback" needs to be a function');const t=e.toString(),s=t.substring(t.indexOf("{")+1,t.lastIndexOf("}"));return this.postMessage({type:"commit",data:s})}message(e){if("function"!==typeof e)throw new TypeError('"message.callback" needs to be a function');this.messageCallback=e}destroy(){this.destroyed=!0,o(this)}}return n});
|
||||
@@ -70,6 +70,29 @@ function cancelRequests(host) {
|
||||
for (const request of [...pending])
|
||||
request.callbacks.reject(new Error("The instance has been destroyed"));
|
||||
}
|
||||
const connections = /* @__PURE__ */ new WeakMap();
|
||||
function connect(host) {
|
||||
const owner = window;
|
||||
const receiver = host.onMessage;
|
||||
connections.set(host, () => owner.removeEventListener("message", receiver));
|
||||
owner.addEventListener("message", receiver);
|
||||
host.$iframe.src = host.url;
|
||||
}
|
||||
function releaseConnection(host) {
|
||||
const disconnect = connections.get(host);
|
||||
connections.delete(host);
|
||||
try {
|
||||
disconnect?.();
|
||||
} finally {
|
||||
cancelRequests(host);
|
||||
}
|
||||
}
|
||||
function acceptsMessage(event, peer) {
|
||||
if (event.source !== void 0 && event.source !== null && event.source !== peer)
|
||||
return false;
|
||||
const data = event.data;
|
||||
return typeof data === "object" && data !== null && "type" in data && typeof data.type === "string";
|
||||
}
|
||||
class ArtplayerToolIframe {
|
||||
static get iframe() {
|
||||
return window.top !== window;
|
||||
@@ -91,6 +114,8 @@ class ArtplayerToolIframe {
|
||||
if (!ArtplayerToolIframe.iframe) {
|
||||
throw new Error('The "ArtplayerToolIframe.onMessage" method can only be used in iframe');
|
||||
}
|
||||
if (!acceptsMessage(event, window.parent))
|
||||
return;
|
||||
const { type, data, id } = event.data;
|
||||
switch (type) {
|
||||
case "commit":
|
||||
@@ -133,11 +158,19 @@ ${data}
|
||||
this.destroyed = false;
|
||||
this.messageCallback = () => null;
|
||||
this.onMessage = this.onMessage.bind(this);
|
||||
window.addEventListener("message", this.onMessage);
|
||||
this.$iframe.src = this.url;
|
||||
try {
|
||||
connect(this);
|
||||
} catch (error) {
|
||||
this.destroyed = true;
|
||||
try {
|
||||
releaseConnection(this);
|
||||
} catch {
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
onMessage(event) {
|
||||
if (this.destroyed)
|
||||
if (this.destroyed || !acceptsMessage(event, this.$iframe.contentWindow))
|
||||
return;
|
||||
const { type, data, id } = event.data;
|
||||
switch (type) {
|
||||
@@ -176,8 +209,7 @@ ${data}
|
||||
}
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
window.removeEventListener("message", this.onMessage);
|
||||
cancelRequests(this);
|
||||
releaseConnection(this);
|
||||
}
|
||||
}
|
||||
export {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Packet, RequestHost } from './requests'
|
||||
import { cancelRequests } from './requests'
|
||||
|
||||
interface ConnectionHost extends RequestHost {
|
||||
url: string
|
||||
onMessage: (event: MessageEvent<Packet>) => void
|
||||
}
|
||||
|
||||
const connections = new WeakMap<ConnectionHost, () => void>()
|
||||
|
||||
export function connect(host: ConnectionHost): void {
|
||||
const owner = window
|
||||
const receiver = host.onMessage
|
||||
connections.set(host, () => owner.removeEventListener('message', receiver))
|
||||
owner.addEventListener('message', receiver)
|
||||
host.$iframe.src = host.url
|
||||
}
|
||||
|
||||
export function releaseConnection(host: ConnectionHost): void {
|
||||
const disconnect = connections.get(host)
|
||||
connections.delete(host)
|
||||
try {
|
||||
disconnect?.()
|
||||
}
|
||||
finally {
|
||||
cancelRequests(host)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Callbacks, Packet } from './requests'
|
||||
import { cancelRequests, postRequest } from './requests'
|
||||
import { connect, releaseConnection } from './connection'
|
||||
import { acceptsMessage } from './protocol'
|
||||
import { postRequest } from './requests'
|
||||
|
||||
export default class ArtplayerToolIframe {
|
||||
declare url: string
|
||||
@@ -33,6 +35,9 @@ export default class ArtplayerToolIframe {
|
||||
throw new Error('The "ArtplayerToolIframe.onMessage" method can only be used in iframe')
|
||||
}
|
||||
|
||||
if (!acceptsMessage(event, window.parent))
|
||||
return
|
||||
|
||||
const { type, data, id } = event.data
|
||||
switch (type) {
|
||||
case 'commit':
|
||||
@@ -84,12 +89,23 @@ export default class ArtplayerToolIframe {
|
||||
this.destroyed = false
|
||||
this.messageCallback = () => null
|
||||
this.onMessage = this.onMessage.bind(this)
|
||||
window.addEventListener('message', this.onMessage)
|
||||
this.$iframe.src = this.url
|
||||
try {
|
||||
connect(this)
|
||||
}
|
||||
catch (error) {
|
||||
this.destroyed = true
|
||||
try {
|
||||
releaseConnection(this)
|
||||
}
|
||||
catch {
|
||||
// Preserve the original setup failure if cleanup also fails.
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
onMessage(event: MessageEvent<Packet>) {
|
||||
if (this.destroyed)
|
||||
if (this.destroyed || !acceptsMessage(event, this.$iframe.contentWindow))
|
||||
return
|
||||
|
||||
const { type, data, id } = event.data
|
||||
@@ -139,7 +155,6 @@ export default class ArtplayerToolIframe {
|
||||
|
||||
destroy() {
|
||||
this.destroyed = true
|
||||
window.removeEventListener('message', this.onMessage)
|
||||
cancelRequests(this)
|
||||
releaseConnection(this)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { Packet } from './requests'
|
||||
|
||||
export function acceptsMessage(event: MessageEvent<unknown>, peer: Window | null): event is MessageEvent<Packet> {
|
||||
if (event.source !== undefined && event.source !== null && event.source !== peer)
|
||||
return false
|
||||
const data = event.data
|
||||
return typeof data === 'object' && data !== null && 'type' in data && typeof data.type === 'string'
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,65 @@
|
||||
# PKG-IFRAME-03 connection and peer boundary checkpoint (doing)
|
||||
|
||||
Connection acquisition/release and packet/peer admission now have separate TS
|
||||
modules. Entry calls them without changing own fields or the bound receiver.
|
||||
Listeners are captured with their owning window and released once; request
|
||||
cancellation runs in finally. Failed setup marks the partial instance destroyed,
|
||||
releases partial registration and cancels reentrant sent/waiting requests. If
|
||||
release also fails, the original constructor error remains the public result.
|
||||
|
||||
The parent processes native packets only from its configured iframe WindowProxy;
|
||||
the child processes them only from window.parent. Null/non-object payloads and
|
||||
non-string types are ignored. Source-less/null-source local onMessage calls,
|
||||
empty-string generic types and invalid commit payload error serialization remain
|
||||
supported. The explicit compatibility/trust decision is ADR-025 in
|
||||
[iframe-message-boundary.md](../iframe-message-boundary.md).
|
||||
|
||||
The first boundary assertion run against the preceding committed build exposed
|
||||
an accidental cross-realm object-prototype comparison in a positive control;
|
||||
copying the packet into the test realm corrected that test without changing
|
||||
production behavior. The final unchanged 18 boundary assertions against b8d03e73
|
||||
pass 2 and fail 16. Candidate source plus previous 16 lifecycle assertions pass 34.
|
||||
The preceding build fails all 12 actual Chromium boundary cases (three scenarios
|
||||
and four origin relationships). Failure reports/results/traces are retained.
|
||||
|
||||
Browser cases use real same/cross-origin windows, actual HTTP302 redirects and
|
||||
opaque sandbox origins, with foreign handshake/response/commit and malformed
|
||||
messages. Separate normal wire controls pair actual npm plugin-iframe@1.0.0 and
|
||||
initial workspace tool main builds with a candidate on either side. They do not
|
||||
claim to secure a receiver still running old code, or to test player/media/demo
|
||||
integration. Final build/node/browser/CI results live in the checkpoint record.
|
||||
|
||||
The first source browser run passed 51 and failed 9 redirect cases because a
|
||||
route-only redirect target was not served by the actual HTTP fixture. The first
|
||||
old-build redirect failures therefore cannot establish product behavior. Both
|
||||
reports are preserved. The fixture now serves its child HTML and HTTP302 from
|
||||
the local server, asserting the observed status and Location. With that fix,
|
||||
the old build fails all 12 boundary cases at the intended peer/payload assertions,
|
||||
and candidate source passes all 60 boundary/mixed-version cases. No production
|
||||
change or timeout widening was used to repair the test setup.
|
||||
|
||||
No protocol fields, public declarations, dependencies, versions or targetOrigin
|
||||
policy change. The additional window admission and ignored malformed packets are
|
||||
intentional defect corrections under API-04/05/12, documented rather than hidden.
|
||||
All other generic message and commit behavior remains protected by historical
|
||||
and candidate tests. Package architecture and executable script entries are
|
||||
updated in the same checkpoint.
|
||||
|
||||
PKG-IFRAME-03 remains doing: navigation and reinjection need document-generation
|
||||
handling that preserves child inject-before-load ordering. Source WindowProxy
|
||||
identity alone does not identify the currently loaded document. All three Iframe
|
||||
risks stay open pending their remaining lifecycle/integration/distribution gates.
|
||||
Revert this checkpoint and run the normal package build to roll back. No push,
|
||||
tag or publication. Commit subject:
|
||||
`fix(iframe): [PKG-IFRAME-03] release failed setup and bind message peers`.
|
||||
|
||||
Final source/main/legacy each pass 34 Node assertions. Browser source passes 60,
|
||||
main 234 (144 historical + 30 lifecycle + 60 boundary/mixed), and legacy 90
|
||||
(30 lifecycle + 60 boundary/mixed), with no skips or unhandled candidate errors.
|
||||
Each boundary suite contains 36 actual peer/payload cases and 24 normal historical
|
||||
wire combinations, including observed server 302/Location and opaque origins.
|
||||
Full CI passes 1523 (1349 unit + 14 engineering + 160 baseline), 44 repeated
|
||||
contracts and 328 production TS files. Three import/SSR files pass. Final source,
|
||||
artifact, old Git build, report hashes and byte-identical docs copies are checked.
|
||||
Task remains doing; no risk closure or release claim. This task began 2026-09-12;
|
||||
completion of this checkpoint is timestamped in the validation record.
|
||||
@@ -32,6 +32,10 @@
|
||||
|
||||
## 开始实现前需补齐的决定
|
||||
|
||||
ADR-025:Iframe绑定实际父子窗口,保留跨源/重定向/opaque-origin与公开local receiver,
|
||||
同时拒绝无关窗口和无效消息。独立依据、兼容影响与剩余信任限制见
|
||||
[Iframe消息边界](iframe-message-boundary.md);本轮不将来源绑定视为导航或全包发布验收。
|
||||
|
||||
1. 支持的历史核心/插件版本、TS 最低版本与浏览器能力矩阵,由 BASE-01/ENG-01 登记证据。
|
||||
2. 连续切源、销毁中 Promise 的结果与错误策略,由 CORE-04/CORE-09 的变更记录明确。
|
||||
3. 真机与外部 SDK 验证环境、负责者和缺失检查的发布处理,由 ENG-05/REL-03 明确。
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# ADR-025:Iframe 的消息对端与公开调用边界
|
||||
|
||||
关联:PKG-IFRAME-03/05,IFRAME-TRUST-01;2026-09-12。
|
||||
状态:已实现方向,浏览器/最终构建结果以本轮验证记录为准;不是全包发布准入。
|
||||
|
||||
## 证据和选择
|
||||
|
||||
IFRAME-01冻结旧npm与初始工作区契约,02用真实WindowProxy复现了无关窗口冒充
|
||||
inject、响应和可执行commit。构造参数明确指定iframe/url,仓库demo使用父页实例与
|
||||
该子页inject配对;没有把所有无关窗口当作合法对端的公开功能说明。
|
||||
|
||||
据此将实际浏览器消息限定到所选窗口:父实例比较event.source与其$iframe.contentWindow,
|
||||
子静态receiver比较event.source与window.parent。非对端消息在更新injected、结算请求、
|
||||
调用message回调和执行commit之前被忽略。对端绑定是已有定向通信的缺陷修正。
|
||||
这是明确记录的安全行为差异,不把历史误接收永久冻结为支持承诺。
|
||||
|
||||
不固定最初URL的origin,不修改targetOrigin='*'。固定初始origin会拒绝现有跨源配对、
|
||||
跳转后的选定子窗口或sandbox opaque-origin;本轮分别用真实同源/跨源、HTTP302和
|
||||
allow-scripts sandbox测试这些情况。窗口身份绑定不意味着验证该窗口当前内容的可信度。
|
||||
父页必须信任选定iframe中的内容;注入子页必须信任其嵌入父页。自动允许任意父页执行
|
||||
commit的部署风险不能通过“它确实是parent”消除,部署准入仍需独立审查。
|
||||
|
||||
公开onMessage仍允许local caller直接调用,source缺省或null时保留原方法用途。
|
||||
此路径也允许具有本页脚本权限的代码自行构造事件;它不提供同页脚本隔离。验证测试的
|
||||
跨窗消息均来自真实浏览器postMessage,不用source-less合成事件冒充已证明的来源校验。
|
||||
|
||||
## 消息与版本兼容
|
||||
|
||||
- 方法、公开字段/描述符、绑定receiver、type/data/id封套和resove保持原样。
|
||||
- 对端的非null对象且type为string才参与协议;空字符串type仍是合法generic消息。
|
||||
- 既有非error同ID响应继续结算;commit函数体/resolve约定及异常发包/拒绝通道不替换。
|
||||
- 旧npm主类和初始workspace主类分别在父/子两侧与候选配对,检查普通同步和异步往返。
|
||||
这些对照证明旧wire可互通,不证明未升级一侧的来源检查已修复;完整防护需升级双方。
|
||||
- 无关窗口的message通知、伪造响应/commit及无效封套的回调/崩溃被主动消除。
|
||||
如果应用确实需要收集其他窗口消息,应在自己的window message监听器中明确管理其对端;
|
||||
不再依赖本工具对任意窗口的历史误处理。
|
||||
- 尚未处理导航握手代际;WindowProxy可跨导航保持身份,不能把来源绑定当作文档身份校验。
|
||||
PKG-IFRAME-03继续处理,IFRAME-05验证完整新旧核心/demo组合,风险未因本检查点自动关闭。
|
||||
|
||||
## 验证与回退
|
||||
|
||||
`test/iframe-boundaries.test.js`覆盖来源、畸形封套、local receiver、setup与destroy失败,
|
||||
`test/browser/iframe-boundaries.spec.js`覆盖真实浏览器和旧wire组合。
|
||||
验证记录:`baselines/iframe-boundaries-checkpoint.json`;历史失败必须与候选验收分开。
|
||||
没有新增依赖、选项、公共类型或包版本。回退本检查点后用正常build重建产物;初始基线保留。
|
||||
+1
-1
@@ -532,5 +532,5 @@
|
||||
- PKG-MB-09: [记录](changes/2026-09-12-PKG-MB-09-native-pip-checkpoint.md) [记录](baselines/mb-native-pip-checkpoint.json) [记录](changes/2026-09-12-PKG-MB-09-sustained-playback-checkpoint.md) [记录](baselines/mb-sustained-validation.json) [记录](baselines/mb-sustained-media.json)
|
||||
- PKG-IFRAME-01: [记录](changes/2026-09-12-PKG-IFRAME-01-contract.md) [记录](baselines/iframe-release.json) [记录](baselines/iframe-contract.md) [记录](baselines/iframe-contract-validation.json)
|
||||
- PKG-IFRAME-02: [记录](changes/2026-09-12-PKG-IFRAME-02-behavior.md) [记录](baselines/iframe-behavior-validation.json)
|
||||
- PKG-IFRAME-03: [记录](changes/2026-09-12-PKG-IFRAME-03-requests-checkpoint.md) [记录](baselines/iframe-requests-checkpoint.json)
|
||||
- PKG-IFRAME-03: [记录](changes/2026-09-12-PKG-IFRAME-03-requests-checkpoint.md) [记录](baselines/iframe-requests-checkpoint.json) [记录](changes/2026-09-12-PKG-IFRAME-03-boundaries-checkpoint.md) [记录](baselines/iframe-boundaries-checkpoint.json) [记录](iframe-message-boundary.md)
|
||||
- PKG-FACTORY-01: [记录](baselines/factory-assignment-gaps.json) [记录](baselines/factory-compatibility-proposals.json) [记录](factory-compatibility-decision.md) [记录](changes/2026-09-12-PKG-FACTORY-01-decision.md)
|
||||
@@ -1,5 +1,19 @@
|
||||
# 进度与证据
|
||||
|
||||
## PKG-IFRAME-03 构造清理与窗口来源检查点(doing)
|
||||
|
||||
增加connection/protocol两个严格TS模块;构造失败清理监听和重入请求,destroy即使
|
||||
移除监听报错仍取消请求。父/子仅处理选定对端的原生消息,忽略畸形封套;保留local
|
||||
onMessage、空type、自定义响应及旧commit协议。ADR-025记录来源绑定、跳转/opaque
|
||||
兼容与仍需信任嵌入父页的限制,未把WindowProxy身份误称作文档身份或代码沙箱。
|
||||
新18断言对上一提交2通过/16失败,候选连同原生命周期各34通过;旧构建实际Chromium
|
||||
12项全部失败。修复HTTP测试夹具后,源码浏览器60、正式main234、legacy90通过;
|
||||
每份边界矩阵含36来源/封套及24旧npm/工作区父子互通,未声称未升级一侧已被修复。
|
||||
完整CI1523与44重复契约、3导入/SSR通过,328生产TS;哈希与docs副本均一致。
|
||||
见[决策](iframe-message-boundary.md)、[变更](changes/2026-09-12-PKG-IFRAME-03-boundaries-checkpoint.md)
|
||||
和[验证](baselines/iframe-boundaries-checkpoint.json)。222项:107 done、10 doing、105 todo。
|
||||
本地检查点提交。下一步导航/重新注入的文档代际与inject-before-load时序;三个风险仍open。
|
||||
|
||||
## PKG-IFRAME-03 请求资源与TS入口检查点(doing)
|
||||
|
||||
入口和请求管理拆为2个严格TS模块,保留7个公开字段及resove/回调/消息协议。
|
||||
|
||||
+9
-4
@@ -3775,7 +3775,7 @@
|
||||
],
|
||||
"status": "open",
|
||||
"confirmation": "reproduced",
|
||||
"compatibleResolution": "Request ownership checkpoint fixes timestamp collisions, polling/send failures and terminal destruction while preserving public resove/registry/envelopes. Navigation/reinjection and setup failure cleanup remain for PKG-IFRAME-03; keep open until complete actual iframe lifecycle acceptance.",
|
||||
"compatibleResolution": "Owned requests fix collision/polling/send/destroy failures; connection cleanup now releases partial construction and cancels requests even if listener removal fails. Navigation/reinjection and document-generation handling remain for PKG-IFRAME-03; keep open until complete actual iframe lifecycle acceptance.",
|
||||
"closureCriteria": "Old behavior and candidate fixes have deterministic race/resource tests plus actual iframe integration.",
|
||||
"evidence": [
|
||||
"refactor/baselines/iframe-contract.md",
|
||||
@@ -3784,7 +3784,9 @@
|
||||
"refactor/baselines/iframe-behavior-validation.json",
|
||||
"refactor/changes/2026-09-12-PKG-IFRAME-02-behavior.md",
|
||||
"refactor/changes/2026-09-12-PKG-IFRAME-03-requests-checkpoint.md",
|
||||
"refactor/baselines/iframe-requests-checkpoint.json"
|
||||
"refactor/baselines/iframe-requests-checkpoint.json",
|
||||
"refactor/changes/2026-09-12-PKG-IFRAME-03-boundaries-checkpoint.md",
|
||||
"refactor/baselines/iframe-boundaries-checkpoint.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -3796,14 +3798,17 @@
|
||||
],
|
||||
"status": "open",
|
||||
"confirmation": "reproduced",
|
||||
"compatibleResolution": "Treat message origin/source and executable commit as an explicit compatibility and trust-boundary decision; do not silently replace the protocol during TS conversion.",
|
||||
"compatibleResolution": "ADR-025 binds native messages to the selected iframe/parent and rejects malformed envelopes while preserving local receiver calls and same/cross/redirect/opaque origins. Executable commit still trusts the embedding parent and selected content; navigation/document generation and complete integration/release review remain open.",
|
||||
"closureCriteria": "Controlled and real same/cross-origin evidence plus an explicit documented compatibility decision and supported trust model.",
|
||||
"evidence": [
|
||||
"refactor/baselines/iframe-contract.md",
|
||||
"refactor/baselines/iframe-release.json",
|
||||
"refactor/changes/2026-09-12-PKG-IFRAME-01-contract.md",
|
||||
"refactor/baselines/iframe-behavior-validation.json",
|
||||
"refactor/changes/2026-09-12-PKG-IFRAME-02-behavior.md"
|
||||
"refactor/changes/2026-09-12-PKG-IFRAME-02-behavior.md",
|
||||
"refactor/changes/2026-09-12-PKG-IFRAME-03-boundaries-checkpoint.md",
|
||||
"refactor/baselines/iframe-boundaries-checkpoint.json",
|
||||
"refactor/iframe-message-boundary.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+4
-1
@@ -3662,7 +3662,10 @@
|
||||
"acceptance": "结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过",
|
||||
"evidence": [
|
||||
"changes/2026-09-12-PKG-IFRAME-03-requests-checkpoint.md",
|
||||
"baselines/iframe-requests-checkpoint.json"
|
||||
"baselines/iframe-requests-checkpoint.json",
|
||||
"changes/2026-09-12-PKG-IFRAME-03-boundaries-checkpoint.md",
|
||||
"baselines/iframe-boundaries-checkpoint.json",
|
||||
"iframe-message-boundary.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import process from 'node:process'
|
||||
import { hash } from '../../refactor/scripts/releases.mjs'
|
||||
import { iframeCandidate, iframeHistorical } from '../helpers/iframe.js'
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
const candidate = await iframeCandidate()
|
||||
const old = (await iframeHistorical()).filter(item => ['published-artplayer-plugin-iframe.js', 'workspace.js'].includes(item.name))
|
||||
const cases = ['same-origin', 'cross-origin', 'opaque-origin', 'redirect'].flatMap(relation => ['parent-guard', 'child-guard', 'malformed'].map(scenario => ({ relation, scenario, parent: candidate, child: candidate })))
|
||||
if (process.env.ARTPLAYER_IFRAME_BOUNDARIES_ONLY !== '1') {
|
||||
for (const historical of old) {
|
||||
for (const relation of ['same-origin', 'cross-origin']) {
|
||||
cases.push({ relation, scenario: 'mixed-parent', parent: candidate, child: historical })
|
||||
cases.push({ relation, scenario: 'mixed-child', parent: historical, child: candidate })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of cases) {
|
||||
test(`Iframe boundary ${item.relation}/${item.scenario}/${item.parent.name}/${item.child.name}`, async ({ page }, testInfo) => {
|
||||
await page.goto('/test/player.html?core=published')
|
||||
const parentOrigin = new URL(page.url()).origin
|
||||
const alternate = parentOrigin.replace('127.0.0.1', 'localhost')
|
||||
const childOrigin = ['cross-origin', 'redirect'].includes(item.relation) ? alternate : parentOrigin
|
||||
const eventOrigin = item.relation === 'opaque-origin' ? 'null' : childOrigin
|
||||
const query = `?auto=${item.scenario === 'parent-guard' ? '0' : '1'}&global=${item.child.global}`
|
||||
const childUrl = `${childOrigin}/test/iframe-boundary-child.html${query}`
|
||||
const initialUrl = item.relation === 'redirect' ? `${parentOrigin}/test/iframe-boundary-redirect${query}` : childUrl
|
||||
const redirects = []
|
||||
page.on('response', (response) => {
|
||||
if (response.url() === initialUrl && response.status() === 302)
|
||||
redirects.push({ url: response.url(), status: response.status(), location: response.headers().location })
|
||||
})
|
||||
await page.route('**/iframe-boundary-lib.js', route => route.fulfill({ contentType: 'text/javascript', body: item.child.code }))
|
||||
await page.route('**/iframe-boundary-foreign', route => route.fulfill({ contentType: 'text/html', body: '<!doctype html><script>parent.postMessage({type:"foreign-ready"},"*")</script>' }))
|
||||
await page.addScriptTag({ content: `(() => { const module = { exports: {} }; const exports = module.exports; ${item.parent.code}; window.IframeFactory = module.exports.default || module.exports; })();` })
|
||||
await page.evaluate(({ initialUrl, opaque }) => {
|
||||
window.packets = []
|
||||
window.states = {}
|
||||
window.callbacks = []
|
||||
const frame = document.createElement('iframe')
|
||||
if (opaque)
|
||||
frame.sandbox = 'allow-scripts'
|
||||
frame.id = 'boundary-target'
|
||||
document.body.append(frame)
|
||||
window.targetFrame = frame
|
||||
addEventListener('message', event => window.packets.push({ type: event.data?.type, data: event.data?.data, id: event.data?.id, fromChild: event.source === frame.contentWindow, origin: event.origin }))
|
||||
window.tool = new window.IframeFactory({ iframe: frame, url: initialUrl })
|
||||
window.tool.message(packet => window.callbacks.push(packet))
|
||||
window.track = (key, promise) => {
|
||||
window.states[key] = { status: 'pending' }
|
||||
promise.then(value => window.states[key] = { status: 'resolved', value }, error => window.states[key] = { status: 'rejected', error: error.message })
|
||||
}
|
||||
}, { initialUrl, opaque: item.relation === 'opaque-origin' })
|
||||
let result
|
||||
try {
|
||||
await expect.poll(() => page.evaluate(() => window.packets.some(packet => packet.type === 'fixture-ready'))).toBe(true)
|
||||
const child = page.frames().find(frame => frame.url() === childUrl)
|
||||
expect(child, 'actual child document after any redirect').toBeTruthy()
|
||||
if (item.relation === 'redirect')
|
||||
expect(redirects).toEqual([{ url: initialUrl, status: 302, location: childUrl }])
|
||||
if (item.scenario === 'parent-guard' || item.scenario === 'child-guard') {
|
||||
await page.evaluate((url) => {
|
||||
const frame = document.createElement('iframe')
|
||||
frame.src = url
|
||||
document.body.append(frame)
|
||||
}, `${alternate}/iframe-boundary-foreign`)
|
||||
await expect.poll(() => page.evaluate(() => window.packets.some(packet => packet.type === 'foreign-ready'))).toBe(true)
|
||||
const foreign = page.frames().find(frame => frame.url().endsWith('/iframe-boundary-foreign'))
|
||||
if (item.scenario === 'parent-guard') {
|
||||
await foreign.evaluate(() => parent.postMessage({ type: 'inject', data: 'forged handshake' }, '*'))
|
||||
await expect.poll(() => page.evaluate(() => window.packets.some(packet => packet.data === 'forged handshake'))).toBe(true)
|
||||
expect(await page.evaluate(() => window.tool.injected)).toBe(false)
|
||||
expect(await page.evaluate(() => window.callbacks.some(packet => packet.data === 'forged handshake'))).toBe(false)
|
||||
await child.evaluate(() => window.Tool.inject())
|
||||
await expect.poll(() => page.evaluate(() => window.tool.injected)).toBe(true)
|
||||
const id = await page.evaluate(() => {
|
||||
window.track('held', window.tool.postMessage({ type: 'hold' }))
|
||||
return Number(Object.keys(window.tool.promises)[0])
|
||||
})
|
||||
await expect.poll(() => child.evaluate(() => Boolean(window.held))).toBe(true)
|
||||
await foreign.evaluate(id => parent.postMessage({ type: 'custom-result', data: 'forged response', id }, '*'), id)
|
||||
await expect.poll(() => page.evaluate(() => window.packets.some(packet => packet.data === 'forged response'))).toBe(true)
|
||||
expect(await page.evaluate(() => window.states.held.status)).toBe('pending')
|
||||
expect(await page.evaluate(() => window.callbacks.some(packet => packet.data === 'forged response'))).toBe(false)
|
||||
await child.evaluate(() => window.Tool.postMessage({ type: 'custom-result', data: 'real response', id: window.held.id }))
|
||||
await expect.poll(() => page.evaluate(() => window.states.held.status)).toBe('resolved')
|
||||
result = await page.evaluate(() => ({ state: window.states.held, pending: Object.keys(window.tool.promises).length, rejectedOrigins: window.packets.filter(packet => packet.data === 'forged handshake' || packet.data === 'forged response').map(packet => ({ origin: packet.origin, fromChild: packet.fromChild })) }))
|
||||
expect(result.state).toEqual({ status: 'resolved', value: 'real response' })
|
||||
expect(result.pending).toBe(0)
|
||||
expect(result.rejectedOrigins).toEqual([{ origin: alternate, fromChild: false }, { origin: alternate, fromChild: false }])
|
||||
}
|
||||
else {
|
||||
await foreign.evaluate(() => parent.frames[0].postMessage({ type: 'commit', data: 'window.executions++; return "foreign executed"', id: 999 }, '*'))
|
||||
await expect.poll(() => page.evaluate(() => window.packets.some(packet => packet.type === 'fixture-observed' && packet.data.id === 999))).toBe(true)
|
||||
expect(await child.evaluate(() => window.executions)).toBe(0)
|
||||
expect(await page.evaluate(() => window.packets.some(packet => packet.data === 'foreign executed'))).toBe(false)
|
||||
const value = await page.evaluate(() => window.tool.commit(() => {
|
||||
window.executions++
|
||||
return 11
|
||||
}))
|
||||
expect(value).toBe(11)
|
||||
result = await child.evaluate(() => ({ executions: window.executions, foreignCommit: window.received.find(packet => packet.id === 999) }))
|
||||
expect(result.executions).toBe(1)
|
||||
expect(result.foreignCommit.fromParent).toBe(false)
|
||||
expect(result.foreignCommit.origin).toBe(alternate)
|
||||
}
|
||||
}
|
||||
else if (item.scenario === 'malformed') {
|
||||
await child.evaluate(() => {
|
||||
for (const payload of [null, false, 3, 'packet', {}, { type: 3 }])
|
||||
parent.postMessage(payload, '*')
|
||||
parent.postMessage({ type: 'fixture-malformed-complete' }, '*')
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.packets.some(packet => packet.type === 'fixture-malformed-complete'))).toBe(true)
|
||||
await page.evaluate(() => {
|
||||
for (const payload of [null, false, 3, 'packet', {}, { type: 3 }])
|
||||
window.targetFrame.contentWindow.postMessage(payload, '*')
|
||||
})
|
||||
await expect.poll(() => child.evaluate(() => window.received.length)).toBe(6)
|
||||
const value = await page.evaluate(() => window.tool.commit(() => {
|
||||
return 12
|
||||
}))
|
||||
expect(value).toBe(12)
|
||||
result = await page.evaluate(() => ({ invalidCallbacks: window.callbacks.filter(packet => typeof packet.type !== 'string').length, pending: Object.keys(window.tool.promises).length }))
|
||||
expect(result).toEqual({ invalidCallbacks: 0, pending: 0 })
|
||||
}
|
||||
else {
|
||||
await expect.poll(() => page.evaluate(() => window.tool.injected)).toBe(true)
|
||||
result = await page.evaluate(async () => {
|
||||
const sync = await window.tool.commit(() => {
|
||||
return 14
|
||||
})
|
||||
const async = await window.tool.commit((resolve) => {
|
||||
setTimeout(() => resolve(15), 5)
|
||||
})
|
||||
return { sync, async, pending: Object.keys(window.tool.promises).length }
|
||||
})
|
||||
expect(result).toEqual({ sync: 14, async: 15, pending: 0 })
|
||||
}
|
||||
const packets = await page.evaluate(() => window.packets)
|
||||
expect(packets.filter(packet => packet.fromChild).every(packet => packet.origin === eventOrigin)).toBe(true)
|
||||
await testInfo.attach('iframe-boundary', { contentType: 'application/json', body: JSON.stringify({ relation: item.relation, scenario: item.scenario, parent: { name: item.parent.name, sha256: hash(item.parent.code) }, child: { name: item.child.name, sha256: hash(item.child.code) }, parentOrigin, initialUrl, childUrl, eventOrigin, redirects, result, packets, scope: 'Actual window peers and native source/origin. Redirect is an HTTP 302 from the local test server; opaque peer is sandbox allow-scripts. Mixed-version rows cover normal wire compatibility only, not security of an unchanged historical peer. No player/media integration.' }) })
|
||||
}
|
||||
finally {
|
||||
await page.evaluate(() => {
|
||||
window.tool.destroy()
|
||||
document.querySelectorAll('iframe').forEach(frame => frame.remove())
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<script src="/iframe-boundary-lib.js"></script>
|
||||
<script>
|
||||
const parameters = new URL(location.href).searchParams;
|
||||
window.Tool = window[parameters.get('global')];
|
||||
window.received = [];
|
||||
window.executions = 0;
|
||||
addEventListener('message', event => {
|
||||
received.push({ type: event.data?.type, id: event.data?.id, fromParent: event.source === parent, origin: event.origin });
|
||||
if (event.data?.type === 'hold') window.held = event.data;
|
||||
parent.postMessage({ type: 'fixture-observed', data: { count: received.length, type: event.data?.type, id: event.data?.id } }, '*');
|
||||
});
|
||||
if (parameters.get('auto') !== '0') Tool.inject();
|
||||
parent.postMessage({ type: 'fixture-ready' }, '*');
|
||||
</script>
|
||||
@@ -75,6 +75,7 @@ const mime = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/jav
|
||||
add('/test/declaration-cues.vtt', fs.readFileSync(path.join(workspace, 'test/browser/media/declaration-cues.vtt')), { kind: 'test-subtitles', file: 'test/browser/media/declaration-cues.vtt' })
|
||||
add('/test/thumbnail-grid.svg', fs.readFileSync(path.join(workspace, 'test/browser/media/thumbnail-grid.svg')), { kind: 'test-thumbnail-grid', file: 'test/browser/media/thumbnail-grid.svg' })
|
||||
add('/test/legacy-safe-area.js', fs.readFileSync(path.join(workspace, 'test/helpers/legacy-safe-area.js')), { kind: 'frozen-own-source', file: 'test/helpers/legacy-safe-area.js', commit: 'ccf77c4e' })
|
||||
add('/test/iframe-boundary-child.html', fs.readFileSync(path.join(workspace, 'test/browser/iframe-boundary-child.html')), { kind: 'iframe-fixture', file: 'test/browser/iframe-boundary-child.html' })
|
||||
function send(req, res, bytes, filename) {
|
||||
res.setHeader('Content-Type', mime[path.extname(filename)] || 'application/octet-stream')
|
||||
res.setHeader('Cache-Control', 'no-store')
|
||||
@@ -103,6 +104,10 @@ const server = http.createServer((req, res) => {
|
||||
assert(['GET', 'HEAD'].includes(req.method), 'Unsupported method')
|
||||
const url = new URL(req.url, `http://127.0.0.1:${port}`)
|
||||
const caseId = url.searchParams.get('case')
|
||||
if (url.pathname === '/test/iframe-boundary-redirect') {
|
||||
res.writeHead(302, { Location: `http://localhost:${port}/test/iframe-boundary-child.html${url.search}` }).end()
|
||||
return
|
||||
}
|
||||
if (url.pathname === '/test/requests.json') {
|
||||
send(req, res, Buffer.from(JSON.stringify(requests.get(caseId) || [])), 'requests.json')
|
||||
return
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import assert from 'node:assert/strict'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Real source compiled into an isolated window for ownership and protocol boundaries.
|
||||
import test from 'node:test'
|
||||
import { iframeCandidate, iframeEnvironment } from './helpers/iframe.js'
|
||||
|
||||
const implementation = await iframeCandidate()
|
||||
const flush = () => new Promise(resolve => setImmediate(resolve))
|
||||
function setup() {
|
||||
const env = iframeEnvironment(implementation)
|
||||
const frame = new env.Frame()
|
||||
const instance = new env.Factory({ iframe: frame, url: '/first' })
|
||||
return { ...env, frame, instance }
|
||||
}
|
||||
function observe(promise) {
|
||||
const state = { status: 'pending' }
|
||||
promise.then(value => Object.assign(state, { status: 'resolved', value }), error => Object.assign(state, { status: 'rejected', error: error.message }))
|
||||
return state
|
||||
}
|
||||
|
||||
test('Iframe source setter failure releases its bound listener and preserves the exact setup error', () => {
|
||||
const env = iframeEnvironment(implementation)
|
||||
const frame = new env.Frame()
|
||||
const failure = new Error('src assignment rejected')
|
||||
Object.defineProperty(frame, 'src', {
|
||||
get() { return '/' },
|
||||
set() {
|
||||
throw failure
|
||||
},
|
||||
})
|
||||
assert.throws(() => new env.Factory({ iframe: frame, url: '/' }), error => error === failure)
|
||||
assert.equal(env.handlers.get('message').size, 0)
|
||||
assert.equal(env.timers.size, 0)
|
||||
})
|
||||
|
||||
test('Iframe partial listener acquisition failure releases ownership and keeps the first error', () => {
|
||||
const env = iframeEnvironment(implementation)
|
||||
const add = env.box.addEventListener
|
||||
const failure = new Error('registration rejected after acquiring')
|
||||
env.box.addEventListener = (...args) => {
|
||||
add(...args)
|
||||
throw failure
|
||||
}
|
||||
assert.throws(() => new env.Factory({ iframe: new env.Frame(), url: '/' }), error => error === failure)
|
||||
assert.equal(env.handlers.get('message').size, 0)
|
||||
assert.equal(env.order.includes('src'), false)
|
||||
})
|
||||
|
||||
test('Iframe setup keeps the source error when releasing a partially constructed instance also throws', () => {
|
||||
const env = iframeEnvironment(implementation)
|
||||
const frame = new env.Frame()
|
||||
const failure = new Error('original source error')
|
||||
Object.defineProperty(frame, 'src', {
|
||||
get() { return '/' },
|
||||
set() {
|
||||
throw failure
|
||||
},
|
||||
})
|
||||
const remove = env.box.removeEventListener
|
||||
let removals = 0
|
||||
env.box.removeEventListener = (...args) => {
|
||||
removals++
|
||||
remove(...args)
|
||||
throw new Error('secondary release error')
|
||||
}
|
||||
assert.throws(() => new env.Factory({ iframe: frame, url: '/' }), error => error === failure)
|
||||
assert.equal(removals, 1)
|
||||
assert.equal(env.handlers.get('message').size, 0)
|
||||
})
|
||||
|
||||
test('Iframe destroy cancels requests even if listener release throws and does not repeat release', async () => {
|
||||
const env = setup()
|
||||
const state = observe(env.instance.postMessage({ type: 'waiting' }))
|
||||
const failure = new Error('listener release failure')
|
||||
const remove = env.box.removeEventListener
|
||||
let removals = 0
|
||||
env.box.removeEventListener = (...args) => {
|
||||
removals++
|
||||
remove(...args)
|
||||
throw failure
|
||||
}
|
||||
assert.throws(() => env.instance.destroy(), error => error === failure)
|
||||
await flush()
|
||||
assert.deepEqual(state, { status: 'rejected', error: 'The instance has been destroyed' })
|
||||
assert.equal(env.timers.size, 0)
|
||||
assert.equal(env.handlers.get('message').size, 0)
|
||||
assert.doesNotThrow(() => env.instance.destroy())
|
||||
assert.equal(removals, 1)
|
||||
})
|
||||
|
||||
for (const injected of [false, true]) {
|
||||
test(`Iframe failed construction cancels a reentrant ${injected ? 'sent' : 'waiting'} request`, async () => {
|
||||
const env = iframeEnvironment(implementation)
|
||||
const frame = new env.Frame()
|
||||
const failure = new Error('source setter failed after reentry')
|
||||
const receive = env.Factory.prototype.onMessage
|
||||
let state
|
||||
let partial
|
||||
env.Factory.prototype.onMessage = function (event) {
|
||||
partial = this
|
||||
receive.call(this, event)
|
||||
state = observe(this.postMessage({ type: 'query' }))
|
||||
}
|
||||
Object.defineProperty(frame, 'src', {
|
||||
get() { return '/' },
|
||||
set() {
|
||||
env.dispatch({ type: injected ? 'inject' : 'custom' }, frame.contentWindow)
|
||||
throw failure
|
||||
},
|
||||
})
|
||||
assert.throws(() => new env.Factory({ iframe: frame, url: '/' }), error => error === failure)
|
||||
await flush()
|
||||
assert.deepEqual(state, { status: 'rejected', error: 'The instance has been destroyed' })
|
||||
assert.equal(partial.destroyed, true)
|
||||
assert.equal(env.timers.size, 0)
|
||||
assert.equal(Object.keys(partial.promises).length, 0)
|
||||
assert.equal(env.handlers.get('message').size, 0)
|
||||
})
|
||||
}
|
||||
|
||||
test('Iframe parent rejects foreign handshakes, responses and callbacks but accepts its selected cross-origin peer', async () => {
|
||||
const env = setup()
|
||||
const seen = []
|
||||
env.instance.message(packet => seen.push(packet.type))
|
||||
env.dispatch({ type: 'inject' }, {})
|
||||
assert.equal(env.instance.injected, false)
|
||||
assert.deepEqual(seen, [])
|
||||
env.dispatch({ type: 'inject' }, env.frame.contentWindow, 'https://redirected.example')
|
||||
const state = observe(env.instance.postMessage({ type: 'query' }))
|
||||
env.dispatch({ type: 'custom-result', data: 'forged', id: 1234 }, {})
|
||||
await flush()
|
||||
assert.deepEqual(state, { status: 'pending' })
|
||||
assert.deepEqual(seen, ['inject'])
|
||||
env.dispatch({ type: 'custom-result', data: 'real', id: 1234 }, env.frame.contentWindow, 'null')
|
||||
await flush()
|
||||
assert.deepEqual(state, { status: 'resolved', value: 'real' })
|
||||
assert.deepEqual(seen, ['inject', 'custom-result'])
|
||||
env.instance.destroy()
|
||||
})
|
||||
|
||||
test('Iframe child rejects a foreign executable command and accepts only its actual parent peer', async () => {
|
||||
const env = iframeEnvironment(implementation, true)
|
||||
env.box.executions = 0
|
||||
const data = { type: 'commit', data: 'window.executions++; return 7', id: 17 }
|
||||
await env.Factory.onMessage({ data, source: {}, origin: 'https://sibling.example' })
|
||||
assert.equal(env.box.executions, 0)
|
||||
assert.equal(env.sent.length, 0)
|
||||
await env.Factory.onMessage({ data, source: env.box.parent, origin: 'https://parent.example' })
|
||||
assert.equal(env.box.executions, 1)
|
||||
assert.equal(env.sent[0].packet.data, 7)
|
||||
})
|
||||
|
||||
for (const data of [null, undefined, false, 3, 'message', {}, { type: null }, { type: 3 }]) {
|
||||
test(`Iframe receivers ignore malformed payload ${JSON.stringify(data)} without callbacks or child rejection`, async () => {
|
||||
const env = setup()
|
||||
let calls = 0
|
||||
env.instance.message(() => calls++)
|
||||
assert.doesNotThrow(() => env.dispatch(data, env.frame.contentWindow))
|
||||
assert.equal(calls, 0)
|
||||
assert.equal(env.instance.injected, false)
|
||||
const child = iframeEnvironment(implementation, true)
|
||||
await assert.doesNotReject(child.Factory.onMessage({ data, source: child.box.parent }))
|
||||
assert.equal(child.sent.length, 0)
|
||||
env.instance.destroy()
|
||||
})
|
||||
}
|
||||
|
||||
test('Iframe public receivers keep source-less and null-source direct invocation as a local API', async () => {
|
||||
const env = setup()
|
||||
env.instance.onMessage({ data: { type: 'inject' } })
|
||||
const promise = env.instance.postMessage({ type: 'query' })
|
||||
env.instance.onMessage({ data: { type: 'custom', data: 9, id: 1234 }, source: null })
|
||||
assert.equal(await promise, 9)
|
||||
const child = iframeEnvironment(implementation, true)
|
||||
await child.Factory.onMessage({ data: { type: 'commit', data: 'return 8', id: 9 }, source: null })
|
||||
assert.equal(child.sent[0].packet.data, 8)
|
||||
env.instance.destroy()
|
||||
})
|
||||
|
||||
test('Iframe trusted protocol retains generic message types and child commit error serialization', async () => {
|
||||
const env = setup()
|
||||
const seen = []
|
||||
env.instance.message(packet => seen.push({ ...packet }))
|
||||
env.dispatch({ type: '', data: 'custom empty type' }, env.frame.contentWindow)
|
||||
assert.deepEqual(seen, [{ type: '', data: 'custom empty type' }])
|
||||
const child = iframeEnvironment(implementation, true)
|
||||
await assert.rejects(child.Factory.onMessage({ data: { type: 'commit', data: 3, id: 7 }, source: child.box.parent }), error => error.name === 'TypeError')
|
||||
assert.equal(child.sent[0].packet.type, 'error')
|
||||
assert.equal(child.sent[0].packet.id, 7)
|
||||
env.instance.destroy()
|
||||
})
|
||||
Reference in new issue
Block a user