fix(iframe): [PKG-IFRAME-03] release failed setup and bind message peers

This commit is contained in:
Harvey Zhao committed 2026-09-13 00:03:37 +08:00
1 parent b8d03e7366
commit b8cdafedd1
23 files changed
+7940 -32

No files matched your search

+1 -1
View File
@@ -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});
+37 -5
View File
@@ -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
View File
@@ -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": {
+29 -4
View File
@@ -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)
}
}
+21 -6
View File
@@ -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.
+4
View File
@@ -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 明确。
+45
View File
@@ -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
View File
@@ -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)
+14
View File
@@ -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
View File
@@ -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
View File
@@ -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"
]
},
{
+151
View File
@@ -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())
})
}
})
}
+15
View File
@@ -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>
+5
View File
@@ -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
+190
View File
@@ -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()
})