mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-08 19:06:15 -08:00
refactor(iframe): [PKG-IFRAME-03] scope requests to active documents
This commit is contained in:
1 parent
b8cdafedd1
commit
1342de94ef
27 files changed
+10860
-69
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;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});
|
||||
!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="artplayer-tool-iframe:session";function t(e){const t=e.__artplayerIframe;if(1===t?.version&&"string"===typeof t.document&&t.document&&["inject","message","ack","leave","resume","fragment"].includes(t.phase))return t}function n(e,t,n){return{...e,__artplayerIframe:{version:1,document:t,phase:n}}}function r(e,t){if(void 0!==e.source&&null!==e.source&&e.source!==t)return!1;const n=e.data;return"object"===typeof n&&null!==n&&"type"in n&&"string"===typeof n.type}let o;const s=new WeakMap,i=new WeakMap;let a=0;function c(e,t){t?i.set(e,t):i.delete(e)}function d(e,t=new Error("The instance has been destroyed")){const n=s.get(e);if(n)for(const r of[...n])r.cancel(t)}const u=new WeakMap;function f(e){const t=e.$iframe;return t.hasAttribute?.("srcdoc")?`srcdoc:${t.getAttribute("srcdoc")}`:`src:${n=t.src||"",n.split("#")[0]}`;var n}function m(e){const t=e.$iframe;return t.hasAttribute?.("srcdoc")?`srcdoc:${t.getAttribute("srcdoc")}`:`src:${t.getAttribute?.("src")??t.src??""}`}function h(e,t){t.waiting||(t.documentId?t.cancelPrevious=function(e){const t=[...s.get(e)||[]];return e=>{for(const n of t)n.cancel(e)}}(e):d(e,new Error("The iframe document has changed"))),t.waiting=!0,e.injected=!1}function l(e,t,n){const r=m(e);if(!n.length&&r===t.rawSource)return;const o=function(e,t,n){return f(e)!==t.source||!!t.documentId&&(m(e)!==t.rawSource||n.some(t=>"srcdoc"===t.attributeName||"src"===t.attributeName&&!e.$iframe.hasAttribute?.("srcdoc")))}(e,t,n);t.rawSource=r,t.source=f(e),o&&h(e,t)}function w(e){const t=u.get(e);if(!t||e.destroyed)return;const n=t.observer?.takeRecords()||[];l(e,t,n)}function p(t,r,o){r.hasHandshake&&r.documentId!==o&&(r.cancelPrevious?r.cancelPrevious(new Error("The iframe document has changed")):r.waiting||d(t,new Error("The iframe document has changed"))),r.cancelPrevious=void 0,r.documentId=o,r.hasHandshake=!0,r.waiting=!1,t.injected=!0;try{t.$iframe.contentWindow?.postMessage(n({type:e,data:void 0,id:0},o,"ack"),"*")}catch{}}function g(n,r){const o=u.get(n);if(!o)return!1;w(n);const s=t(r);if(s&&r.type===e)return"resume"===s.phase?p(n,o,s.document):s.document===o.documentId&&("leave"===s.phase?function(e,t){t.cancelPrevious?t.cancelPrevious(new Error("The iframe document has changed")):t.waiting||d(e,new Error("The iframe document has changed")),t.cancelPrevious=void 0,t.waiting=!0,e.injected=!1}(n,o):"fragment"===s.phase&&o.cancelPrevious&&!n.$iframe.hasAttribute?.("srcdoc")&&r.data===n.$iframe.src&&p(n,o,s.document)),!0;if("inject"===r.type){if("inject"===s?.phase){if(o.waiting&&s.document===o.documentId)return!0;p(n,o,s.document)}else o.waiting&&(o.cancelPrevious?.(new Error("The iframe document has changed")),o.cancelPrevious=void 0,o.documentId=void 0),o.hasHandshake=!0,o.waiting=!1;return!1}return Boolean(s&&(o.waiting&&!o.cancelPrevious||s.document!==o.documentId))}const y=new WeakMap;function v(e){const t=window,r=e.onMessage,o=[()=>t.removeEventListener("message",r)];y.set(e,()=>{let e,t=!1;for(const r of o)try{r()}catch(n){t||(t=!0,e=n)}if(t)throw e}),t.addEventListener("message",r),e.$iframe.src=e.url;const s=function(e){const t={source:f(e),rawSource:m(e),hasHandshake:!1,waiting:!1};return u.set(e,t),c(e,{prepare:()=>w(e),envelope:e=>t.documentId?n(e,t.documentId,"message"):e}),{start(){"function"===typeof window.MutationObserver&&(t.observer=new window.MutationObserver(n=>{e.destroyed||l(e,t,n)}),t.observer.observe(e.$iframe,{attributes:!0,attributeOldValue:!0,attributeFilter:["src","srcdoc"]}))},dispose(){u.delete(e),c(e,void 0),t.cancelPrevious=void 0,t.observer?.disconnect()}}}(e);o.push(s.dispose),s.start()}function b(e){const t=y.get(e);y.delete(e);try{t?.()}finally{d(e)}}class E{static get iframe(){return window.top!==window}static postMessage({type:e,data:t,id:r=0}){if(!E.iframe)throw new Error('The "ArtplayerToolIframe.postMessage" method can only be used in iframe');var s;window.parent.postMessage((s={type:e,data:t,id:r},o&&("inject"===s.type||o.acknowledged)?n(s,o.id,"inject"===s.type?"inject":"message"):s),"*")}static async onMessage(n){if(!E.iframe)throw new Error('The "ArtplayerToolIframe.onMessage" method can only be used in iframe');if(!r(n,window.parent)||function(n){const r=t(n);return!!r&&(n.type===e?(o&&r.document===o.id&&"ack"===r.phase&&(o.acknowledged=!0),!0):!o||r.document!==o.id)}(n.data))return;const{type:s,data:i,id:a}=n.data;if("commit"===s)try{if(i.match(/\bresolve\((.*?)\)/)){const e=`return new Promise(function(resolve){\n${i}\n})`,t=await new Function(e)();E.postMessage({type:"response",data:t,id:a})}else{const e=new Function(i)();E.postMessage({type:"response",data:e,id:a})}}catch(c){throw E.postMessage({type:"error",data:c.message,Line truncated
|
||||
@@ -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;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});
|
||||
!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";var e=Object.defineProperty,t=Object.defineProperties,n=Object.getOwnPropertyDescriptors,r=Object.getOwnPropertySymbols,o=Object.prototype.hasOwnProperty,s=Object.prototype.propertyIsEnumerable,i=(t,n,r)=>n in t?e(t,n,{enumerable:!0,configurable:!0,writable:!0,value:r}):t[n]=r;const a="artplayer-tool-iframe:session";function c(e){const t=e.__artplayerIframe;if(1===(null==t?void 0:t.version)&&"string"===typeof t.document&&t.document&&["inject","message","ack","leave","resume","fragment"].includes(t.phase))return t}function d(e,a,c){return d=((e,t)=>{for(var n in t||(t={}))o.call(t,n)&&i(e,n,t[n]);if(r)for(var n of r(t))s.call(t,n)&&i(e,n,t[n]);return e})({},e),t(d,n({__artplayerIframe:{version:1,document:a,phase:c}}));var d}function u(e,t){if(void 0!==e.source&&null!==e.source&&e.source!==t)return!1;const n=e.data;return"object"===typeof n&&null!==n&&"type"in n&&"string"===typeof n.type}let l;const f=new WeakMap,m=new WeakMap;let h=0;function p(e,t){t?m.set(e,t):m.delete(e)}function w(e,t=new Error("The instance has been destroyed")){const n=f.get(e);if(n)for(const r of[...n])r.cancel(t)}const v=new WeakMap;function y(e){var t;const n=e.$iframe;return(null==(t=n.hasAttribute)?void 0:t.call(n,"srcdoc"))?`srcdoc:${n.getAttribute("srcdoc")}`:`src:${r=n.src||"",r.split("#")[0]}`;var r}function g(e){var t,n,r,o;const s=e.$iframe;return(null==(t=s.hasAttribute)?void 0:t.call(s,"srcdoc"))?`srcdoc:${s.getAttribute("srcdoc")}`:`src:${null!=(o=null!=(r=null==(n=s.getAttribute)?void 0:n.call(s,"src"))?r:s.src)?o:""}`}function b(e,t){t.waiting||(t.documentId?t.cancelPrevious=function(e){const t=[...f.get(e)||[]];return e=>{for(const n of t)n.cancel(e)}}(e):w(e,new Error("The iframe document has changed"))),t.waiting=!0,e.injected=!1}function E(e,t,n){const r=g(e);if(!n.length&&r===t.rawSource)return;const o=function(e,t,n){return y(e)!==t.source||!!t.documentId&&(g(e)!==t.rawSource||n.some(t=>{var n,r;return"srcdoc"===t.attributeName||"src"===t.attributeName&&!(null==(r=(n=e.$iframe).hasAttribute)?void 0:r.call(n,"srcdoc"))}))}(e,t,n);t.rawSource=r,t.source=y(e),o&&b(e,t)}function M(e){var t;const n=v.get(e);if(!n||e.destroyed)return;E(e,n,(null==(t=n.observer)?void 0:t.takeRecords())||[])}function j(e,t,n){var r;t.hasHandshake&&t.documentId!==n&&(t.cancelPrevious?t.cancelPrevious(new Error("The iframe document has changed")):t.waiting||w(e,new Error("The iframe document has changed"))),t.cancelPrevious=void 0,t.documentId=n,t.hasHandshake=!0,t.waiting=!1,e.injected=!0;try{null==(r=e.$iframe.contentWindow)||r.postMessage(d({type:a,data:void 0,id:0},n,"ack"),"*")}catch(o){}}function T(e,t){var n,r,o;const s=v.get(e);if(!s)return!1;M(e);const i=c(t);if(i&&t.type===a)return"resume"===i.phase?j(e,s,i.document):i.document===s.documentId&&("leave"===i.phase?function(e,t){t.cancelPrevious?t.cancelPrevious(new Error("The iframe document has changed")):t.waiting||w(e,new Error("The iframe document has changed")),t.cancelPrevious=void 0,t.waiting=!0,e.injected=!1}(e,s):"fragment"===i.phase&&s.cancelPrevious&&!(null==(r=(n=e.$iframe).hasAttribute)?void 0:r.call(n,"srcdoc"))&&t.data===e.$iframe.src&&j(e,s,i.document)),!0;if("inject"===t.type){if("inject"===(null==i?void 0:i.phase)){if(s.waiting&&i.document===s.documentId)return!0;j(e,s,i.document)}else s.waiting&&(null==(o=s.cancelPrevious)||o.call(s,new Error("The iframe document has changed")),s.cancelPrevious=void 0,s.documentId=void 0),s.hasHandshake=!0,s.waiting=!1;return!1}return Boolean(i&&(s.waiting&&!s.cancelPrevious||i.document!==s.documentId))}const k=new WeakMap;function P(e){const t=window,n=e.onMessage,r=[()=>t.removeEventListener("message",n)];k.set(e,()=>{let e,t=!1;for(const o of r)try{o()}catch(n){t||(t=!0,e=n)}if(t)throw e}),t.addEventListener("message",n),e.$iframe.src=e.url;const o=function(e){const t={source:y(e),rawSource:g(e),hasHandshake:!1,waiting:!1};return v.set(e,t),p(e,{prepare:()=>M(e),envelope:e=>t.documentId?d(e,t.documentId,"message"):e}),{start(){"function"===typeof window.MutationObserver&&(t.observer=new window.MutationObserver(n=>{e.destroyed||E(e,t,n)}),t.observer.observe(e.$iframe,{attributes:!0,attributeOldValue:!0,attributeFilter:["src","srcdoc"]}))},dispose(){var n;v.delete(e),p(e,void 0),t.cancelPrevious=void 0,null==(n=t.observer)||n.disconnect()}}}(e);r.push(o.dispose),o.start()}function I(e){const t=k.get(e);k.delete(e);try{null==t||t()}finally{w(e)}}class ${static get iframe(){return window.top!==window}static postMessage({type:e,data:t,id:n=0}){if(!$.iframe)throw new Error('The "ArtplayerToolIframe.postMessage" method can only be used in iframe');var r;window.parent.postMessage((r={type:e,data:t,id:n},l&&("inject"===r.type||lLine truncated
|
||||
@@ -4,8 +4,104 @@
|
||||
* (c) 2017-2026 Harvey Zhao
|
||||
* Released under the MIT License.
|
||||
*/
|
||||
const sessionType = "artplayer-tool-iframe:session";
|
||||
function sessionMetadata(packet) {
|
||||
const value = packet.__artplayerIframe;
|
||||
if (value?.version === 1 && typeof value.document === "string" && value.document && ["inject", "message", "ack", "leave", "resume", "fragment"].includes(value.phase))
|
||||
return value;
|
||||
}
|
||||
function withSession(packet, documentId, phase) {
|
||||
return { ...packet, __artplayerIframe: { version: 1, document: documentId, phase } };
|
||||
}
|
||||
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";
|
||||
}
|
||||
let session;
|
||||
function prepareChildSession() {
|
||||
if (session?.owner === window.document && session)
|
||||
return;
|
||||
const random = new Uint32Array(4);
|
||||
if (window.crypto?.getRandomValues)
|
||||
window.crypto.getRandomValues(random);
|
||||
else
|
||||
random.forEach((_, index) => random[index] = Math.floor(Math.random() * 4294967296));
|
||||
session = { owner: window.document, id: `${Date.now()}-${Array.from(random).join("-")}`, acknowledged: false, lifecycle: false, receivers: /* @__PURE__ */ new Set() };
|
||||
}
|
||||
function childEnvelope(packet) {
|
||||
if (session && (packet.type === "inject" || session.acknowledged))
|
||||
return withSession(packet, session.id, packet.type === "inject" ? "inject" : "message");
|
||||
return packet;
|
||||
}
|
||||
function consumeChildSession(packet) {
|
||||
const metadata = sessionMetadata(packet);
|
||||
if (!metadata)
|
||||
return false;
|
||||
if (packet.type === sessionType) {
|
||||
if (session && metadata.document === session.id && metadata.phase === "ack")
|
||||
session.acknowledged = true;
|
||||
return true;
|
||||
}
|
||||
return !session || metadata.document !== session.id;
|
||||
}
|
||||
function connectChildSession(receiver) {
|
||||
const current = session;
|
||||
const added = !current.receivers.has(receiver);
|
||||
const installing = !current.lifecycle;
|
||||
const notify = (phase, data) => {
|
||||
if (!current.acknowledged)
|
||||
return;
|
||||
try {
|
||||
window.parent.postMessage(withSession({ type: sessionType, data, id: 0 }, current.id, phase), "*");
|
||||
} catch {
|
||||
}
|
||||
};
|
||||
const leave = () => notify("leave");
|
||||
const fragment = () => notify("fragment", window.location.href);
|
||||
const resume = (event) => {
|
||||
if (event.persisted)
|
||||
notify("resume");
|
||||
};
|
||||
try {
|
||||
window.addEventListener("message", receiver);
|
||||
current.receivers.add(receiver);
|
||||
if (!installing)
|
||||
return;
|
||||
window.addEventListener("pagehide", leave);
|
||||
window.addEventListener("pageshow", resume);
|
||||
window.addEventListener("hashchange", fragment);
|
||||
current.lifecycle = true;
|
||||
} catch (error) {
|
||||
const cleanups = [];
|
||||
if (installing) {
|
||||
cleanups.push(() => window.removeEventListener("pagehide", leave));
|
||||
cleanups.push(() => window.removeEventListener("pageshow", resume));
|
||||
cleanups.push(() => window.removeEventListener("hashchange", fragment));
|
||||
}
|
||||
if (added)
|
||||
cleanups.push(() => window.removeEventListener("message", receiver));
|
||||
for (const cleanup of cleanups) {
|
||||
try {
|
||||
cleanup();
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
if (added)
|
||||
current.receivers.delete(receiver);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const requests = /* @__PURE__ */ new WeakMap();
|
||||
const boundaries = /* @__PURE__ */ new WeakMap();
|
||||
let lastId = 0;
|
||||
function setRequestBoundary(host, boundary) {
|
||||
if (boundary)
|
||||
boundaries.set(host, boundary);
|
||||
else
|
||||
boundaries.delete(host);
|
||||
}
|
||||
function postRequest(host, { type, data }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (host.destroyed) {
|
||||
@@ -35,6 +131,10 @@ function postRequest(host, { type, data }) {
|
||||
if (finish(request))
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
cancel(error) {
|
||||
if (finish(request))
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
pending.add(request);
|
||||
@@ -43,40 +143,209 @@ function postRequest(host, { type, data }) {
|
||||
if (!pending.has(request))
|
||||
return;
|
||||
if (host.destroyed) {
|
||||
request.callbacks.reject(new Error("The instance has been destroyed"));
|
||||
request.cancel(new Error("The instance has been destroyed"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
boundaries.get(host)?.prepare();
|
||||
if (!pending.has(request))
|
||||
return;
|
||||
if (host.injected) {
|
||||
const id = Math.max(Date.now(), lastId + 1);
|
||||
lastId = id;
|
||||
request.id = id;
|
||||
host.promises[id] = request.callbacks;
|
||||
host.$iframe.contentWindow.postMessage({ type, data, id }, "*");
|
||||
const packet = { type, data, id };
|
||||
host.$iframe.contentWindow.postMessage(boundaries.get(host)?.envelope(packet) || packet, "*");
|
||||
} else {
|
||||
request.timer = setTimeout(loop, 200);
|
||||
}
|
||||
} catch (error) {
|
||||
request.callbacks.reject(error);
|
||||
request.cancel(error);
|
||||
}
|
||||
};
|
||||
loop();
|
||||
});
|
||||
}
|
||||
function cancelRequests(host) {
|
||||
function cancelRequests(host, reason = new Error("The instance has been destroyed")) {
|
||||
const pending = requests.get(host);
|
||||
if (!pending)
|
||||
return;
|
||||
for (const request of [...pending])
|
||||
request.callbacks.reject(new Error("The instance has been destroyed"));
|
||||
request.cancel(reason);
|
||||
}
|
||||
function captureRequests(host) {
|
||||
const captured = [...requests.get(host) || []];
|
||||
return (reason) => {
|
||||
for (const request of captured)
|
||||
request.cancel(reason);
|
||||
};
|
||||
}
|
||||
const navigations = /* @__PURE__ */ new WeakMap();
|
||||
const withoutHash = (value) => value.split("#")[0];
|
||||
function sourceOf(host) {
|
||||
const frame = host.$iframe;
|
||||
return frame.hasAttribute?.("srcdoc") ? `srcdoc:${frame.getAttribute("srcdoc")}` : `src:${withoutHash(frame.src || "")}`;
|
||||
}
|
||||
function rawSourceOf(host) {
|
||||
const frame = host.$iframe;
|
||||
return frame.hasAttribute?.("srcdoc") ? `srcdoc:${frame.getAttribute("srcdoc")}` : `src:${frame.getAttribute?.("src") ?? frame.src ?? ""}`;
|
||||
}
|
||||
function invalidate(host, state) {
|
||||
if (!state.waiting) {
|
||||
if (state.documentId)
|
||||
state.cancelPrevious = captureRequests(host);
|
||||
else
|
||||
cancelRequests(host, new Error("The iframe document has changed"));
|
||||
}
|
||||
state.waiting = true;
|
||||
host.injected = false;
|
||||
}
|
||||
function leaveDocument(host, state) {
|
||||
if (state.cancelPrevious)
|
||||
state.cancelPrevious(new Error("The iframe document has changed"));
|
||||
else if (!state.waiting)
|
||||
cancelRequests(host, new Error("The iframe document has changed"));
|
||||
state.cancelPrevious = void 0;
|
||||
state.waiting = true;
|
||||
host.injected = false;
|
||||
}
|
||||
function sourceChanged(host, state, records) {
|
||||
if (sourceOf(host) !== state.source)
|
||||
return true;
|
||||
if (!state.documentId)
|
||||
return false;
|
||||
if (rawSourceOf(host) !== state.rawSource)
|
||||
return true;
|
||||
return records.some((record) => {
|
||||
if (record.attributeName === "srcdoc")
|
||||
return true;
|
||||
if (record.attributeName !== "src" || host.$iframe.hasAttribute?.("srcdoc"))
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
function observeSource(host, state, records) {
|
||||
const rawSource = rawSourceOf(host);
|
||||
if (!records.length && rawSource === state.rawSource)
|
||||
return;
|
||||
const changed = sourceChanged(host, state, records);
|
||||
state.rawSource = rawSource;
|
||||
state.source = sourceOf(host);
|
||||
if (changed)
|
||||
invalidate(host, state);
|
||||
}
|
||||
function prepareNavigation(host) {
|
||||
const state = navigations.get(host);
|
||||
if (!state || host.destroyed)
|
||||
return;
|
||||
const records = state.observer?.takeRecords() || [];
|
||||
observeSource(host, state, records);
|
||||
}
|
||||
function createNavigation(host) {
|
||||
const state = { source: sourceOf(host), rawSource: rawSourceOf(host), hasHandshake: false, waiting: false };
|
||||
navigations.set(host, state);
|
||||
setRequestBoundary(host, {
|
||||
prepare: () => prepareNavigation(host),
|
||||
envelope: (packet) => state.documentId ? withSession(packet, state.documentId, "message") : packet
|
||||
});
|
||||
return {
|
||||
start() {
|
||||
if (typeof window.MutationObserver !== "function")
|
||||
return;
|
||||
state.observer = new window.MutationObserver((records) => {
|
||||
if (host.destroyed)
|
||||
return;
|
||||
observeSource(host, state, records);
|
||||
});
|
||||
state.observer.observe(host.$iframe, { attributes: true, attributeOldValue: true, attributeFilter: ["src", "srcdoc"] });
|
||||
},
|
||||
dispose() {
|
||||
navigations.delete(host);
|
||||
setRequestBoundary(host, void 0);
|
||||
state.cancelPrevious = void 0;
|
||||
state.observer?.disconnect();
|
||||
}
|
||||
};
|
||||
}
|
||||
function activate(host, state, documentId) {
|
||||
if (state.hasHandshake && state.documentId !== documentId) {
|
||||
if (state.cancelPrevious)
|
||||
state.cancelPrevious(new Error("The iframe document has changed"));
|
||||
else if (!state.waiting)
|
||||
cancelRequests(host, new Error("The iframe document has changed"));
|
||||
}
|
||||
state.cancelPrevious = void 0;
|
||||
state.documentId = documentId;
|
||||
state.hasHandshake = true;
|
||||
state.waiting = false;
|
||||
host.injected = true;
|
||||
try {
|
||||
host.$iframe.contentWindow?.postMessage(withSession({ type: sessionType, data: void 0, id: 0 }, documentId, "ack"), "*");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
function consumeNavigation(host, packet) {
|
||||
const state = navigations.get(host);
|
||||
if (!state)
|
||||
return false;
|
||||
prepareNavigation(host);
|
||||
const metadata = sessionMetadata(packet);
|
||||
if (metadata && packet.type === sessionType) {
|
||||
if (metadata.phase === "resume") {
|
||||
activate(host, state, metadata.document);
|
||||
} else if (metadata.document === state.documentId) {
|
||||
if (metadata.phase === "leave")
|
||||
leaveDocument(host, state);
|
||||
else if (metadata.phase === "fragment" && state.cancelPrevious && !host.$iframe.hasAttribute?.("srcdoc") && packet.data === host.$iframe.src)
|
||||
activate(host, state, metadata.document);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (packet.type === "inject") {
|
||||
if (metadata?.phase === "inject") {
|
||||
if (state.waiting && metadata.document === state.documentId)
|
||||
return true;
|
||||
activate(host, state, metadata.document);
|
||||
} else {
|
||||
if (state.waiting) {
|
||||
state.cancelPrevious?.(new Error("The iframe document has changed"));
|
||||
state.cancelPrevious = void 0;
|
||||
state.documentId = void 0;
|
||||
}
|
||||
state.hasHandshake = true;
|
||||
state.waiting = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return Boolean(metadata && (state.waiting && !state.cancelPrevious || metadata.document !== state.documentId));
|
||||
}
|
||||
const connections = /* @__PURE__ */ new WeakMap();
|
||||
function connect(host) {
|
||||
const owner = window;
|
||||
const receiver = host.onMessage;
|
||||
connections.set(host, () => owner.removeEventListener("message", receiver));
|
||||
const cleanups = [() => owner.removeEventListener("message", receiver)];
|
||||
connections.set(host, () => {
|
||||
let failed = false;
|
||||
let failure;
|
||||
for (const cleanup of cleanups) {
|
||||
try {
|
||||
cleanup();
|
||||
} catch (error) {
|
||||
if (!failed) {
|
||||
failed = true;
|
||||
failure = error;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (failed)
|
||||
throw failure;
|
||||
});
|
||||
owner.addEventListener("message", receiver);
|
||||
host.$iframe.src = host.url;
|
||||
const navigation = createNavigation(host);
|
||||
cleanups.push(navigation.dispose);
|
||||
navigation.start();
|
||||
}
|
||||
function releaseConnection(host) {
|
||||
const disconnect = connections.get(host);
|
||||
@@ -87,12 +356,6 @@ function releaseConnection(host) {
|
||||
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;
|
||||
@@ -102,11 +365,11 @@ class ArtplayerToolIframe {
|
||||
throw new Error('The "ArtplayerToolIframe.postMessage" method can only be used in iframe');
|
||||
}
|
||||
window.parent.postMessage(
|
||||
{
|
||||
childEnvelope({
|
||||
type,
|
||||
data,
|
||||
id
|
||||
},
|
||||
}),
|
||||
"*"
|
||||
);
|
||||
}
|
||||
@@ -114,7 +377,7 @@ class ArtplayerToolIframe {
|
||||
if (!ArtplayerToolIframe.iframe) {
|
||||
throw new Error('The "ArtplayerToolIframe.onMessage" method can only be used in iframe');
|
||||
}
|
||||
if (!acceptsMessage(event, window.parent))
|
||||
if (!acceptsMessage(event, window.parent) || consumeChildSession(event.data))
|
||||
return;
|
||||
const { type, data, id } = event.data;
|
||||
switch (type) {
|
||||
@@ -141,8 +404,9 @@ ${data}
|
||||
if (!ArtplayerToolIframe.iframe) {
|
||||
throw new Error('The "ArtplayerToolIframe.inject" method can only be used in iframe');
|
||||
}
|
||||
prepareChildSession();
|
||||
ArtplayerToolIframe.postMessage({ type: "inject" });
|
||||
window.addEventListener("message", ArtplayerToolIframe.onMessage);
|
||||
connectChildSession(ArtplayerToolIframe.onMessage);
|
||||
}
|
||||
constructor({ iframe, url }) {
|
||||
if (iframe instanceof HTMLIFrameElement === false) {
|
||||
@@ -170,7 +434,7 @@ ${data}
|
||||
}
|
||||
}
|
||||
onMessage(event) {
|
||||
if (this.destroyed || !acceptsMessage(event, this.$iframe.contentWindow))
|
||||
if (this.destroyed || !acceptsMessage(event, this.$iframe.contentWindow) || consumeNavigation(this, event.data))
|
||||
return;
|
||||
const { type, data, id } = event.data;
|
||||
switch (type) {
|
||||
@@ -191,6 +455,7 @@ ${data}
|
||||
}
|
||||
}
|
||||
postMessage(message) {
|
||||
prepareNavigation(this);
|
||||
return postRequest(this, message);
|
||||
}
|
||||
commit(callback) {
|
||||
|
||||
+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-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:unit": "node --test test/iframe-navigation.test.js 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-boundaries.test.js test/iframe.test.js test/iframe-lifecycle.test.js refactor/scripts/iframe-contract.test.mjs"
|
||||
"test:iframe": "node --test test/iframe-navigation.test.js 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": {
|
||||
|
||||
@@ -8,6 +8,11 @@ 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.
|
||||
`src/navigation.ts` owns parent document state, source observation and suspended
|
||||
request snapshots. `src/child-session.ts` owns the child document marker and its
|
||||
acknowledged pagehide/pageshow/hashchange notifications. Requests accept an
|
||||
internal boundary hook rather than importing the connection or navigation modules;
|
||||
there is no runtime dependency cycle or dependency on a particular core version.
|
||||
|
||||
The entry initializes the same seven enumerable, writable fields in the same
|
||||
order. Internal request ownership lives in a module WeakMap, keeping instance
|
||||
@@ -44,6 +49,36 @@ The public ID is a correlation number, and is no longer always equal to Date.now
|
||||
Public callback invocation now removes its completed registry entry immediately.
|
||||
The own-property guard prevents inherited names such as `toString` being treated
|
||||
as requests. These differences are recorded under PKG-IFRAME-03.
|
||||
Internal cancellation holds the native rejection separately from public callback
|
||||
properties. Replacing/deleting a public entry does not make its owned Promise
|
||||
unreachable during destroy or document departure; ordinary response dispatch
|
||||
continues to respect the public callbacks.
|
||||
|
||||
## Document ownership
|
||||
|
||||
New inject packets advertise optional `__artplayerIframe` metadata. A cooperating
|
||||
parent acknowledges it through tagged `artplayer-tool-iframe:session` traffic;
|
||||
the public message callback still receives only its original type/data packet.
|
||||
Ordinary child messages stay unmarked when the parent has not acknowledged the
|
||||
extension. The document ID correlates work and is not an authentication token.
|
||||
See [ADR-026](../../refactor/iframe-document-protocol.md) for the complete wire boundary.
|
||||
|
||||
Parent source attribute changes begin a provisional navigation: capture the old
|
||||
pending set and pause new sends. Confirmed pagehide or a different document's
|
||||
inject cancels the captured set, preserving work queued for the new document.
|
||||
A hashchange report matching the actual target src completes a same-document
|
||||
transition without cancellation. This also handles relative/absolute src spelling
|
||||
and setting an old fragment after the child changed its own hash. Raw attribute
|
||||
tracking prevents a changed base URL resolution alone from inventing navigation.
|
||||
|
||||
Repeated inject for the active document keeps requests; an old inject cannot
|
||||
reactivate a document while its replacement is pending. Marked replies and
|
||||
notifications from other documents are discarded; marked old commands are not
|
||||
executed by a new child. No load-event reset is used, so inject-before-load works.
|
||||
Connection cleanup disposes the observer and request hook, then cancels all owned
|
||||
requests. Child listeners belong to the child document, are installed once, and
|
||||
are rolled back on partial acquisition failure. A destroyed parent ignores late
|
||||
private notifications. The existing API adds no static child destroy method.
|
||||
|
||||
## Compatibility still under review
|
||||
|
||||
@@ -68,9 +103,12 @@ 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.
|
||||
Legacy peers keep the ordinary envelope and support observed changes to different
|
||||
source addresses; their same-address reloads/internal navigation cannot provide
|
||||
document identity without upgrading the child. No claim is made that a normal
|
||||
history reload is BFCache: actual persisted restoration, full-player integration,
|
||||
devices and externally interrupted navigation remain PKG-IFRAME-05/release gates.
|
||||
IFRAME-LIFE-01 and IFRAME-TRUST-01 remain open for those integration/review scopes.
|
||||
|
||||
Public declarations remain in `types/artplayer-tool-iframe.d.ts`; source typing
|
||||
does not yet establish historical declaration/entry compatibility. PKG-IFRAME-04
|
||||
@@ -92,6 +130,10 @@ publication is implied by this source migration.
|
||||
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.
|
||||
- `yarn test:browser test/browser/iframe-navigation.spec.js`: source/srcdoc, rapid
|
||||
navigation, self-navigation, load ordering, fragment identity, stale packets,
|
||||
legacy children and history with actual persisted-state reporting. There is no
|
||||
implicit request timeout. Handle rejection when a document leaves or is destroyed.
|
||||
- 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;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});
|
||||
!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="artplayer-tool-iframe:session";function t(e){const t=e.__artplayerIframe;if(1===t?.version&&"string"===typeof t.document&&t.document&&["inject","message","ack","leave","resume","fragment"].includes(t.phase))return t}function n(e,t,n){return{...e,__artplayerIframe:{version:1,document:t,phase:n}}}function r(e,t){if(void 0!==e.source&&null!==e.source&&e.source!==t)return!1;const n=e.data;return"object"===typeof n&&null!==n&&"type"in n&&"string"===typeof n.type}let o;const s=new WeakMap,i=new WeakMap;let a=0;function c(e,t){t?i.set(e,t):i.delete(e)}function d(e,t=new Error("The instance has been destroyed")){const n=s.get(e);if(n)for(const r of[...n])r.cancel(t)}const u=new WeakMap;function f(e){const t=e.$iframe;return t.hasAttribute?.("srcdoc")?`srcdoc:${t.getAttribute("srcdoc")}`:`src:${n=t.src||"",n.split("#")[0]}`;var n}function m(e){const t=e.$iframe;return t.hasAttribute?.("srcdoc")?`srcdoc:${t.getAttribute("srcdoc")}`:`src:${t.getAttribute?.("src")??t.src??""}`}function h(e,t){t.waiting||(t.documentId?t.cancelPrevious=function(e){const t=[...s.get(e)||[]];return e=>{for(const n of t)n.cancel(e)}}(e):d(e,new Error("The iframe document has changed"))),t.waiting=!0,e.injected=!1}function l(e,t,n){const r=m(e);if(!n.length&&r===t.rawSource)return;const o=function(e,t,n){return f(e)!==t.source||!!t.documentId&&(m(e)!==t.rawSource||n.some(t=>"srcdoc"===t.attributeName||"src"===t.attributeName&&!e.$iframe.hasAttribute?.("srcdoc")))}(e,t,n);t.rawSource=r,t.source=f(e),o&&h(e,t)}function w(e){const t=u.get(e);if(!t||e.destroyed)return;const n=t.observer?.takeRecords()||[];l(e,t,n)}function p(t,r,o){r.hasHandshake&&r.documentId!==o&&(r.cancelPrevious?r.cancelPrevious(new Error("The iframe document has changed")):r.waiting||d(t,new Error("The iframe document has changed"))),r.cancelPrevious=void 0,r.documentId=o,r.hasHandshake=!0,r.waiting=!1,t.injected=!0;try{t.$iframe.contentWindow?.postMessage(n({type:e,data:void 0,id:0},o,"ack"),"*")}catch{}}function g(n,r){const o=u.get(n);if(!o)return!1;w(n);const s=t(r);if(s&&r.type===e)return"resume"===s.phase?p(n,o,s.document):s.document===o.documentId&&("leave"===s.phase?function(e,t){t.cancelPrevious?t.cancelPrevious(new Error("The iframe document has changed")):t.waiting||d(e,new Error("The iframe document has changed")),t.cancelPrevious=void 0,t.waiting=!0,e.injected=!1}(n,o):"fragment"===s.phase&&o.cancelPrevious&&!n.$iframe.hasAttribute?.("srcdoc")&&r.data===n.$iframe.src&&p(n,o,s.document)),!0;if("inject"===r.type){if("inject"===s?.phase){if(o.waiting&&s.document===o.documentId)return!0;p(n,o,s.document)}else o.waiting&&(o.cancelPrevious?.(new Error("The iframe document has changed")),o.cancelPrevious=void 0,o.documentId=void 0),o.hasHandshake=!0,o.waiting=!1;return!1}return Boolean(s&&(o.waiting&&!o.cancelPrevious||s.document!==o.documentId))}const y=new WeakMap;function v(e){const t=window,r=e.onMessage,o=[()=>t.removeEventListener("message",r)];y.set(e,()=>{let e,t=!1;for(const r of o)try{r()}catch(n){t||(t=!0,e=n)}if(t)throw e}),t.addEventListener("message",r),e.$iframe.src=e.url;const s=function(e){const t={source:f(e),rawSource:m(e),hasHandshake:!1,waiting:!1};return u.set(e,t),c(e,{prepare:()=>w(e),envelope:e=>t.documentId?n(e,t.documentId,"message"):e}),{start(){"function"===typeof window.MutationObserver&&(t.observer=new window.MutationObserver(n=>{e.destroyed||l(e,t,n)}),t.observer.observe(e.$iframe,{attributes:!0,attributeOldValue:!0,attributeFilter:["src","srcdoc"]}))},dispose(){u.delete(e),c(e,void 0),t.cancelPrevious=void 0,t.observer?.disconnect()}}}(e);o.push(s.dispose),s.start()}function b(e){const t=y.get(e);y.delete(e);try{t?.()}finally{d(e)}}class E{static get iframe(){return window.top!==window}static postMessage({type:e,data:t,id:r=0}){if(!E.iframe)throw new Error('The "ArtplayerToolIframe.postMessage" method can only be used in iframe');var s;window.parent.postMessage((s={type:e,data:t,id:r},o&&("inject"===s.type||o.acknowledged)?n(s,o.id,"inject"===s.type?"inject":"message"):s),"*")}static async onMessage(n){if(!E.iframe)throw new Error('The "ArtplayerToolIframe.onMessage" method can only be used in iframe');if(!r(n,window.parent)||function(n){const r=t(n);return!!r&&(n.type===e?(o&&r.document===o.id&&"ack"===r.phase&&(o.acknowledged=!0),!0):!o||r.document!==o.id)}(n.data))return;const{type:s,data:i,id:a}=n.data;if("commit"===s)try{if(i.match(/\bresolve\((.*?)\)/)){const e=`return new Promise(function(resolve){\n${i}\n})`,t=await new Function(e)();E.postMessage({type:"response",data:t,id:a})}else{const e=new Function(i)();E.postMessage({type:"response",data:e,id:a})}}catch(c){throw E.postMessage({type:"error",data:c.message,Line truncated
|
||||
@@ -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;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});
|
||||
!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";var e=Object.defineProperty,t=Object.defineProperties,n=Object.getOwnPropertyDescriptors,r=Object.getOwnPropertySymbols,o=Object.prototype.hasOwnProperty,s=Object.prototype.propertyIsEnumerable,i=(t,n,r)=>n in t?e(t,n,{enumerable:!0,configurable:!0,writable:!0,value:r}):t[n]=r;const a="artplayer-tool-iframe:session";function c(e){const t=e.__artplayerIframe;if(1===(null==t?void 0:t.version)&&"string"===typeof t.document&&t.document&&["inject","message","ack","leave","resume","fragment"].includes(t.phase))return t}function d(e,a,c){return d=((e,t)=>{for(var n in t||(t={}))o.call(t,n)&&i(e,n,t[n]);if(r)for(var n of r(t))s.call(t,n)&&i(e,n,t[n]);return e})({},e),t(d,n({__artplayerIframe:{version:1,document:a,phase:c}}));var d}function u(e,t){if(void 0!==e.source&&null!==e.source&&e.source!==t)return!1;const n=e.data;return"object"===typeof n&&null!==n&&"type"in n&&"string"===typeof n.type}let l;const f=new WeakMap,m=new WeakMap;let h=0;function p(e,t){t?m.set(e,t):m.delete(e)}function w(e,t=new Error("The instance has been destroyed")){const n=f.get(e);if(n)for(const r of[...n])r.cancel(t)}const v=new WeakMap;function y(e){var t;const n=e.$iframe;return(null==(t=n.hasAttribute)?void 0:t.call(n,"srcdoc"))?`srcdoc:${n.getAttribute("srcdoc")}`:`src:${r=n.src||"",r.split("#")[0]}`;var r}function g(e){var t,n,r,o;const s=e.$iframe;return(null==(t=s.hasAttribute)?void 0:t.call(s,"srcdoc"))?`srcdoc:${s.getAttribute("srcdoc")}`:`src:${null!=(o=null!=(r=null==(n=s.getAttribute)?void 0:n.call(s,"src"))?r:s.src)?o:""}`}function b(e,t){t.waiting||(t.documentId?t.cancelPrevious=function(e){const t=[...f.get(e)||[]];return e=>{for(const n of t)n.cancel(e)}}(e):w(e,new Error("The iframe document has changed"))),t.waiting=!0,e.injected=!1}function E(e,t,n){const r=g(e);if(!n.length&&r===t.rawSource)return;const o=function(e,t,n){return y(e)!==t.source||!!t.documentId&&(g(e)!==t.rawSource||n.some(t=>{var n,r;return"srcdoc"===t.attributeName||"src"===t.attributeName&&!(null==(r=(n=e.$iframe).hasAttribute)?void 0:r.call(n,"srcdoc"))}))}(e,t,n);t.rawSource=r,t.source=y(e),o&&b(e,t)}function M(e){var t;const n=v.get(e);if(!n||e.destroyed)return;E(e,n,(null==(t=n.observer)?void 0:t.takeRecords())||[])}function j(e,t,n){var r;t.hasHandshake&&t.documentId!==n&&(t.cancelPrevious?t.cancelPrevious(new Error("The iframe document has changed")):t.waiting||w(e,new Error("The iframe document has changed"))),t.cancelPrevious=void 0,t.documentId=n,t.hasHandshake=!0,t.waiting=!1,e.injected=!0;try{null==(r=e.$iframe.contentWindow)||r.postMessage(d({type:a,data:void 0,id:0},n,"ack"),"*")}catch(o){}}function T(e,t){var n,r,o;const s=v.get(e);if(!s)return!1;M(e);const i=c(t);if(i&&t.type===a)return"resume"===i.phase?j(e,s,i.document):i.document===s.documentId&&("leave"===i.phase?function(e,t){t.cancelPrevious?t.cancelPrevious(new Error("The iframe document has changed")):t.waiting||w(e,new Error("The iframe document has changed")),t.cancelPrevious=void 0,t.waiting=!0,e.injected=!1}(e,s):"fragment"===i.phase&&s.cancelPrevious&&!(null==(r=(n=e.$iframe).hasAttribute)?void 0:r.call(n,"srcdoc"))&&t.data===e.$iframe.src&&j(e,s,i.document)),!0;if("inject"===t.type){if("inject"===(null==i?void 0:i.phase)){if(s.waiting&&i.document===s.documentId)return!0;j(e,s,i.document)}else s.waiting&&(null==(o=s.cancelPrevious)||o.call(s,new Error("The iframe document has changed")),s.cancelPrevious=void 0,s.documentId=void 0),s.hasHandshake=!0,s.waiting=!1;return!1}return Boolean(i&&(s.waiting&&!s.cancelPrevious||i.document!==s.documentId))}const k=new WeakMap;function P(e){const t=window,n=e.onMessage,r=[()=>t.removeEventListener("message",n)];k.set(e,()=>{let e,t=!1;for(const o of r)try{o()}catch(n){t||(t=!0,e=n)}if(t)throw e}),t.addEventListener("message",n),e.$iframe.src=e.url;const o=function(e){const t={source:y(e),rawSource:g(e),hasHandshake:!1,waiting:!1};return v.set(e,t),p(e,{prepare:()=>M(e),envelope:e=>t.documentId?d(e,t.documentId,"message"):e}),{start(){"function"===typeof window.MutationObserver&&(t.observer=new window.MutationObserver(n=>{e.destroyed||E(e,t,n)}),t.observer.observe(e.$iframe,{attributes:!0,attributeOldValue:!0,attributeFilter:["src","srcdoc"]}))},dispose(){var n;v.delete(e),p(e,void 0),t.cancelPrevious=void 0,null==(n=t.observer)||n.disconnect()}}}(e);r.push(o.dispose),o.start()}function I(e){const t=k.get(e);k.delete(e);try{null==t||t()}finally{w(e)}}class ${static get iframe(){return window.top!==window}static postMessage({type:e,data:t,id:n=0}){if(!$.iframe)throw new Error('The "ArtplayerToolIframe.postMessage" method can only be used in iframe');var r;window.parent.postMessage((r={type:e,data:t,id:n},l&&("inject"===r.type||lLine truncated
|
||||
+282
-17
@@ -4,8 +4,104 @@
|
||||
* (c) 2017-2026 Harvey Zhao
|
||||
* Released under the MIT License.
|
||||
*/
|
||||
const sessionType = "artplayer-tool-iframe:session";
|
||||
function sessionMetadata(packet) {
|
||||
const value = packet.__artplayerIframe;
|
||||
if (value?.version === 1 && typeof value.document === "string" && value.document && ["inject", "message", "ack", "leave", "resume", "fragment"].includes(value.phase))
|
||||
return value;
|
||||
}
|
||||
function withSession(packet, documentId, phase) {
|
||||
return { ...packet, __artplayerIframe: { version: 1, document: documentId, phase } };
|
||||
}
|
||||
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";
|
||||
}
|
||||
let session;
|
||||
function prepareChildSession() {
|
||||
if (session?.owner === window.document && session)
|
||||
return;
|
||||
const random = new Uint32Array(4);
|
||||
if (window.crypto?.getRandomValues)
|
||||
window.crypto.getRandomValues(random);
|
||||
else
|
||||
random.forEach((_, index) => random[index] = Math.floor(Math.random() * 4294967296));
|
||||
session = { owner: window.document, id: `${Date.now()}-${Array.from(random).join("-")}`, acknowledged: false, lifecycle: false, receivers: /* @__PURE__ */ new Set() };
|
||||
}
|
||||
function childEnvelope(packet) {
|
||||
if (session && (packet.type === "inject" || session.acknowledged))
|
||||
return withSession(packet, session.id, packet.type === "inject" ? "inject" : "message");
|
||||
return packet;
|
||||
}
|
||||
function consumeChildSession(packet) {
|
||||
const metadata = sessionMetadata(packet);
|
||||
if (!metadata)
|
||||
return false;
|
||||
if (packet.type === sessionType) {
|
||||
if (session && metadata.document === session.id && metadata.phase === "ack")
|
||||
session.acknowledged = true;
|
||||
return true;
|
||||
}
|
||||
return !session || metadata.document !== session.id;
|
||||
}
|
||||
function connectChildSession(receiver) {
|
||||
const current = session;
|
||||
const added = !current.receivers.has(receiver);
|
||||
const installing = !current.lifecycle;
|
||||
const notify = (phase, data) => {
|
||||
if (!current.acknowledged)
|
||||
return;
|
||||
try {
|
||||
window.parent.postMessage(withSession({ type: sessionType, data, id: 0 }, current.id, phase), "*");
|
||||
} catch {
|
||||
}
|
||||
};
|
||||
const leave = () => notify("leave");
|
||||
const fragment = () => notify("fragment", window.location.href);
|
||||
const resume = (event) => {
|
||||
if (event.persisted)
|
||||
notify("resume");
|
||||
};
|
||||
try {
|
||||
window.addEventListener("message", receiver);
|
||||
current.receivers.add(receiver);
|
||||
if (!installing)
|
||||
return;
|
||||
window.addEventListener("pagehide", leave);
|
||||
window.addEventListener("pageshow", resume);
|
||||
window.addEventListener("hashchange", fragment);
|
||||
current.lifecycle = true;
|
||||
} catch (error) {
|
||||
const cleanups = [];
|
||||
if (installing) {
|
||||
cleanups.push(() => window.removeEventListener("pagehide", leave));
|
||||
cleanups.push(() => window.removeEventListener("pageshow", resume));
|
||||
cleanups.push(() => window.removeEventListener("hashchange", fragment));
|
||||
}
|
||||
if (added)
|
||||
cleanups.push(() => window.removeEventListener("message", receiver));
|
||||
for (const cleanup of cleanups) {
|
||||
try {
|
||||
cleanup();
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
if (added)
|
||||
current.receivers.delete(receiver);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const requests = /* @__PURE__ */ new WeakMap();
|
||||
const boundaries = /* @__PURE__ */ new WeakMap();
|
||||
let lastId = 0;
|
||||
function setRequestBoundary(host, boundary) {
|
||||
if (boundary)
|
||||
boundaries.set(host, boundary);
|
||||
else
|
||||
boundaries.delete(host);
|
||||
}
|
||||
function postRequest(host, { type, data }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (host.destroyed) {
|
||||
@@ -35,6 +131,10 @@ function postRequest(host, { type, data }) {
|
||||
if (finish(request))
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
cancel(error) {
|
||||
if (finish(request))
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
pending.add(request);
|
||||
@@ -43,40 +143,209 @@ function postRequest(host, { type, data }) {
|
||||
if (!pending.has(request))
|
||||
return;
|
||||
if (host.destroyed) {
|
||||
request.callbacks.reject(new Error("The instance has been destroyed"));
|
||||
request.cancel(new Error("The instance has been destroyed"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
boundaries.get(host)?.prepare();
|
||||
if (!pending.has(request))
|
||||
return;
|
||||
if (host.injected) {
|
||||
const id = Math.max(Date.now(), lastId + 1);
|
||||
lastId = id;
|
||||
request.id = id;
|
||||
host.promises[id] = request.callbacks;
|
||||
host.$iframe.contentWindow.postMessage({ type, data, id }, "*");
|
||||
const packet = { type, data, id };
|
||||
host.$iframe.contentWindow.postMessage(boundaries.get(host)?.envelope(packet) || packet, "*");
|
||||
} else {
|
||||
request.timer = setTimeout(loop, 200);
|
||||
}
|
||||
} catch (error) {
|
||||
request.callbacks.reject(error);
|
||||
request.cancel(error);
|
||||
}
|
||||
};
|
||||
loop();
|
||||
});
|
||||
}
|
||||
function cancelRequests(host) {
|
||||
function cancelRequests(host, reason = new Error("The instance has been destroyed")) {
|
||||
const pending = requests.get(host);
|
||||
if (!pending)
|
||||
return;
|
||||
for (const request of [...pending])
|
||||
request.callbacks.reject(new Error("The instance has been destroyed"));
|
||||
request.cancel(reason);
|
||||
}
|
||||
function captureRequests(host) {
|
||||
const captured = [...requests.get(host) || []];
|
||||
return (reason) => {
|
||||
for (const request of captured)
|
||||
request.cancel(reason);
|
||||
};
|
||||
}
|
||||
const navigations = /* @__PURE__ */ new WeakMap();
|
||||
const withoutHash = (value) => value.split("#")[0];
|
||||
function sourceOf(host) {
|
||||
const frame = host.$iframe;
|
||||
return frame.hasAttribute?.("srcdoc") ? `srcdoc:${frame.getAttribute("srcdoc")}` : `src:${withoutHash(frame.src || "")}`;
|
||||
}
|
||||
function rawSourceOf(host) {
|
||||
const frame = host.$iframe;
|
||||
return frame.hasAttribute?.("srcdoc") ? `srcdoc:${frame.getAttribute("srcdoc")}` : `src:${frame.getAttribute?.("src") ?? frame.src ?? ""}`;
|
||||
}
|
||||
function invalidate(host, state) {
|
||||
if (!state.waiting) {
|
||||
if (state.documentId)
|
||||
state.cancelPrevious = captureRequests(host);
|
||||
else
|
||||
cancelRequests(host, new Error("The iframe document has changed"));
|
||||
}
|
||||
state.waiting = true;
|
||||
host.injected = false;
|
||||
}
|
||||
function leaveDocument(host, state) {
|
||||
if (state.cancelPrevious)
|
||||
state.cancelPrevious(new Error("The iframe document has changed"));
|
||||
else if (!state.waiting)
|
||||
cancelRequests(host, new Error("The iframe document has changed"));
|
||||
state.cancelPrevious = void 0;
|
||||
state.waiting = true;
|
||||
host.injected = false;
|
||||
}
|
||||
function sourceChanged(host, state, records) {
|
||||
if (sourceOf(host) !== state.source)
|
||||
return true;
|
||||
if (!state.documentId)
|
||||
return false;
|
||||
if (rawSourceOf(host) !== state.rawSource)
|
||||
return true;
|
||||
return records.some((record) => {
|
||||
if (record.attributeName === "srcdoc")
|
||||
return true;
|
||||
if (record.attributeName !== "src" || host.$iframe.hasAttribute?.("srcdoc"))
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
function observeSource(host, state, records) {
|
||||
const rawSource = rawSourceOf(host);
|
||||
if (!records.length && rawSource === state.rawSource)
|
||||
return;
|
||||
const changed = sourceChanged(host, state, records);
|
||||
state.rawSource = rawSource;
|
||||
state.source = sourceOf(host);
|
||||
if (changed)
|
||||
invalidate(host, state);
|
||||
}
|
||||
function prepareNavigation(host) {
|
||||
const state = navigations.get(host);
|
||||
if (!state || host.destroyed)
|
||||
return;
|
||||
const records = state.observer?.takeRecords() || [];
|
||||
observeSource(host, state, records);
|
||||
}
|
||||
function createNavigation(host) {
|
||||
const state = { source: sourceOf(host), rawSource: rawSourceOf(host), hasHandshake: false, waiting: false };
|
||||
navigations.set(host, state);
|
||||
setRequestBoundary(host, {
|
||||
prepare: () => prepareNavigation(host),
|
||||
envelope: (packet) => state.documentId ? withSession(packet, state.documentId, "message") : packet
|
||||
});
|
||||
return {
|
||||
start() {
|
||||
if (typeof window.MutationObserver !== "function")
|
||||
return;
|
||||
state.observer = new window.MutationObserver((records) => {
|
||||
if (host.destroyed)
|
||||
return;
|
||||
observeSource(host, state, records);
|
||||
});
|
||||
state.observer.observe(host.$iframe, { attributes: true, attributeOldValue: true, attributeFilter: ["src", "srcdoc"] });
|
||||
},
|
||||
dispose() {
|
||||
navigations.delete(host);
|
||||
setRequestBoundary(host, void 0);
|
||||
state.cancelPrevious = void 0;
|
||||
state.observer?.disconnect();
|
||||
}
|
||||
};
|
||||
}
|
||||
function activate(host, state, documentId) {
|
||||
if (state.hasHandshake && state.documentId !== documentId) {
|
||||
if (state.cancelPrevious)
|
||||
state.cancelPrevious(new Error("The iframe document has changed"));
|
||||
else if (!state.waiting)
|
||||
cancelRequests(host, new Error("The iframe document has changed"));
|
||||
}
|
||||
state.cancelPrevious = void 0;
|
||||
state.documentId = documentId;
|
||||
state.hasHandshake = true;
|
||||
state.waiting = false;
|
||||
host.injected = true;
|
||||
try {
|
||||
host.$iframe.contentWindow?.postMessage(withSession({ type: sessionType, data: void 0, id: 0 }, documentId, "ack"), "*");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
function consumeNavigation(host, packet) {
|
||||
const state = navigations.get(host);
|
||||
if (!state)
|
||||
return false;
|
||||
prepareNavigation(host);
|
||||
const metadata = sessionMetadata(packet);
|
||||
if (metadata && packet.type === sessionType) {
|
||||
if (metadata.phase === "resume") {
|
||||
activate(host, state, metadata.document);
|
||||
} else if (metadata.document === state.documentId) {
|
||||
if (metadata.phase === "leave")
|
||||
leaveDocument(host, state);
|
||||
else if (metadata.phase === "fragment" && state.cancelPrevious && !host.$iframe.hasAttribute?.("srcdoc") && packet.data === host.$iframe.src)
|
||||
activate(host, state, metadata.document);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (packet.type === "inject") {
|
||||
if (metadata?.phase === "inject") {
|
||||
if (state.waiting && metadata.document === state.documentId)
|
||||
return true;
|
||||
activate(host, state, metadata.document);
|
||||
} else {
|
||||
if (state.waiting) {
|
||||
state.cancelPrevious?.(new Error("The iframe document has changed"));
|
||||
state.cancelPrevious = void 0;
|
||||
state.documentId = void 0;
|
||||
}
|
||||
state.hasHandshake = true;
|
||||
state.waiting = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return Boolean(metadata && (state.waiting && !state.cancelPrevious || metadata.document !== state.documentId));
|
||||
}
|
||||
const connections = /* @__PURE__ */ new WeakMap();
|
||||
function connect(host) {
|
||||
const owner = window;
|
||||
const receiver = host.onMessage;
|
||||
connections.set(host, () => owner.removeEventListener("message", receiver));
|
||||
const cleanups = [() => owner.removeEventListener("message", receiver)];
|
||||
connections.set(host, () => {
|
||||
let failed = false;
|
||||
let failure;
|
||||
for (const cleanup of cleanups) {
|
||||
try {
|
||||
cleanup();
|
||||
} catch (error) {
|
||||
if (!failed) {
|
||||
failed = true;
|
||||
failure = error;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (failed)
|
||||
throw failure;
|
||||
});
|
||||
owner.addEventListener("message", receiver);
|
||||
host.$iframe.src = host.url;
|
||||
const navigation = createNavigation(host);
|
||||
cleanups.push(navigation.dispose);
|
||||
navigation.start();
|
||||
}
|
||||
function releaseConnection(host) {
|
||||
const disconnect = connections.get(host);
|
||||
@@ -87,12 +356,6 @@ function releaseConnection(host) {
|
||||
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;
|
||||
@@ -102,11 +365,11 @@ class ArtplayerToolIframe {
|
||||
throw new Error('The "ArtplayerToolIframe.postMessage" method can only be used in iframe');
|
||||
}
|
||||
window.parent.postMessage(
|
||||
{
|
||||
childEnvelope({
|
||||
type,
|
||||
data,
|
||||
id
|
||||
},
|
||||
}),
|
||||
"*"
|
||||
);
|
||||
}
|
||||
@@ -114,7 +377,7 @@ class ArtplayerToolIframe {
|
||||
if (!ArtplayerToolIframe.iframe) {
|
||||
throw new Error('The "ArtplayerToolIframe.onMessage" method can only be used in iframe');
|
||||
}
|
||||
if (!acceptsMessage(event, window.parent))
|
||||
if (!acceptsMessage(event, window.parent) || consumeChildSession(event.data))
|
||||
return;
|
||||
const { type, data, id } = event.data;
|
||||
switch (type) {
|
||||
@@ -141,8 +404,9 @@ ${data}
|
||||
if (!ArtplayerToolIframe.iframe) {
|
||||
throw new Error('The "ArtplayerToolIframe.inject" method can only be used in iframe');
|
||||
}
|
||||
prepareChildSession();
|
||||
ArtplayerToolIframe.postMessage({ type: "inject" });
|
||||
window.addEventListener("message", ArtplayerToolIframe.onMessage);
|
||||
connectChildSession(ArtplayerToolIframe.onMessage);
|
||||
}
|
||||
constructor({ iframe, url }) {
|
||||
if (iframe instanceof HTMLIFrameElement === false) {
|
||||
@@ -170,7 +434,7 @@ ${data}
|
||||
}
|
||||
}
|
||||
onMessage(event) {
|
||||
if (this.destroyed || !acceptsMessage(event, this.$iframe.contentWindow))
|
||||
if (this.destroyed || !acceptsMessage(event, this.$iframe.contentWindow) || consumeNavigation(this, event.data))
|
||||
return;
|
||||
const { type, data, id } = event.data;
|
||||
switch (type) {
|
||||
@@ -191,6 +455,7 @@ ${data}
|
||||
}
|
||||
}
|
||||
postMessage(message) {
|
||||
prepareNavigation(this);
|
||||
return postRequest(this, message);
|
||||
}
|
||||
commit(callback) {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { Packet } from './requests'
|
||||
import { sessionMetadata, sessionType, withSession } from './protocol'
|
||||
|
||||
interface ChildSession {
|
||||
owner: Document
|
||||
id: string
|
||||
acknowledged: boolean
|
||||
lifecycle: boolean
|
||||
receivers: Set<(event: MessageEvent<Packet>) => void>
|
||||
}
|
||||
|
||||
let session: ChildSession | undefined
|
||||
|
||||
export function prepareChildSession(): void {
|
||||
if (session?.owner === window.document && session)
|
||||
return
|
||||
const random = new Uint32Array(4)
|
||||
if (window.crypto?.getRandomValues)
|
||||
window.crypto.getRandomValues(random)
|
||||
else
|
||||
random.forEach((_, index) => random[index] = Math.floor(Math.random() * 0x100000000))
|
||||
session = { owner: window.document, id: `${Date.now()}-${Array.from(random).join('-')}`, acknowledged: false, lifecycle: false, receivers: new Set() }
|
||||
}
|
||||
|
||||
export function childEnvelope(packet: Packet): Packet {
|
||||
if (session && (packet.type === 'inject' || session.acknowledged))
|
||||
return withSession(packet, session.id, packet.type === 'inject' ? 'inject' : 'message')
|
||||
return packet
|
||||
}
|
||||
|
||||
export function consumeChildSession(packet: Packet): boolean {
|
||||
const metadata = sessionMetadata(packet)
|
||||
if (!metadata)
|
||||
return false
|
||||
if (packet.type === sessionType) {
|
||||
if (session && metadata.document === session.id && metadata.phase === 'ack')
|
||||
session.acknowledged = true
|
||||
return true
|
||||
}
|
||||
return !session || metadata.document !== session.id
|
||||
}
|
||||
|
||||
export function connectChildSession(receiver: (event: MessageEvent<Packet>) => void): void {
|
||||
const current = session!
|
||||
const added = !current.receivers.has(receiver)
|
||||
const installing = !current.lifecycle
|
||||
const notify = (phase: 'leave' | 'resume' | 'fragment', data?: unknown) => {
|
||||
if (!current.acknowledged)
|
||||
return
|
||||
try {
|
||||
window.parent.postMessage(withSession({ type: sessionType, data, id: 0 }, current.id, phase), '*')
|
||||
}
|
||||
catch {
|
||||
// The parent may have disappeared; this private lifecycle notification is best effort.
|
||||
}
|
||||
}
|
||||
const leave = () => notify('leave')
|
||||
const fragment = () => notify('fragment', window.location.href)
|
||||
const resume = (event: PageTransitionEvent) => {
|
||||
if (event.persisted)
|
||||
notify('resume')
|
||||
}
|
||||
try {
|
||||
window.addEventListener('message', receiver)
|
||||
current.receivers.add(receiver)
|
||||
if (!installing)
|
||||
return
|
||||
window.addEventListener('pagehide', leave)
|
||||
window.addEventListener('pageshow', resume)
|
||||
window.addEventListener('hashchange', fragment)
|
||||
current.lifecycle = true
|
||||
}
|
||||
catch (error) {
|
||||
const cleanups: (() => void)[] = []
|
||||
if (installing) {
|
||||
cleanups.push(() => window.removeEventListener('pagehide', leave))
|
||||
cleanups.push(() => window.removeEventListener('pageshow', resume))
|
||||
cleanups.push(() => window.removeEventListener('hashchange', fragment))
|
||||
}
|
||||
if (added)
|
||||
cleanups.push(() => window.removeEventListener('message', receiver))
|
||||
for (const cleanup of cleanups) {
|
||||
try {
|
||||
cleanup()
|
||||
}
|
||||
catch { /* Preserve the acquisition error while releasing the other listeners. */ }
|
||||
}
|
||||
if (added)
|
||||
current.receivers.delete(receiver)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Packet, RequestHost } from './requests'
|
||||
import { createNavigation } from './navigation'
|
||||
import { cancelRequests } from './requests'
|
||||
|
||||
interface ConnectionHost extends RequestHost {
|
||||
@@ -11,9 +12,29 @@ 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))
|
||||
const cleanups = [() => owner.removeEventListener('message', receiver)]
|
||||
connections.set(host, () => {
|
||||
let failed = false
|
||||
let failure: unknown
|
||||
for (const cleanup of cleanups) {
|
||||
try {
|
||||
cleanup()
|
||||
}
|
||||
catch (error) {
|
||||
if (!failed) {
|
||||
failed = true
|
||||
failure = error
|
||||
}
|
||||
}
|
||||
}
|
||||
if (failed)
|
||||
throw failure
|
||||
})
|
||||
owner.addEventListener('message', receiver)
|
||||
host.$iframe.src = host.url
|
||||
const navigation = createNavigation(host)
|
||||
cleanups.push(navigation.dispose)
|
||||
navigation.start()
|
||||
}
|
||||
|
||||
export function releaseConnection(host: ConnectionHost): void {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Callbacks, Packet } from './requests'
|
||||
import { childEnvelope, connectChildSession, consumeChildSession, prepareChildSession } from './child-session'
|
||||
import { connect, releaseConnection } from './connection'
|
||||
import { consumeNavigation, prepareNavigation } from './navigation'
|
||||
import { acceptsMessage } from './protocol'
|
||||
import { postRequest } from './requests'
|
||||
|
||||
@@ -21,11 +23,11 @@ export default class ArtplayerToolIframe {
|
||||
}
|
||||
|
||||
window.parent.postMessage(
|
||||
{
|
||||
childEnvelope({
|
||||
type,
|
||||
data,
|
||||
id,
|
||||
},
|
||||
}),
|
||||
'*',
|
||||
)
|
||||
}
|
||||
@@ -35,7 +37,7 @@ export default class ArtplayerToolIframe {
|
||||
throw new Error('The "ArtplayerToolIframe.onMessage" method can only be used in iframe')
|
||||
}
|
||||
|
||||
if (!acceptsMessage(event, window.parent))
|
||||
if (!acceptsMessage(event, window.parent) || consumeChildSession(event.data))
|
||||
return
|
||||
|
||||
const { type, data, id } = event.data
|
||||
@@ -69,8 +71,9 @@ export default class ArtplayerToolIframe {
|
||||
throw new Error('The "ArtplayerToolIframe.inject" method can only be used in iframe')
|
||||
}
|
||||
|
||||
prepareChildSession()
|
||||
ArtplayerToolIframe.postMessage({ type: 'inject' })
|
||||
window.addEventListener('message', ArtplayerToolIframe.onMessage)
|
||||
connectChildSession(ArtplayerToolIframe.onMessage)
|
||||
}
|
||||
|
||||
constructor({ iframe, url }: { iframe: HTMLIFrameElement, url: string }) {
|
||||
@@ -105,7 +108,7 @@ export default class ArtplayerToolIframe {
|
||||
}
|
||||
|
||||
onMessage(event: MessageEvent<Packet>) {
|
||||
if (this.destroyed || !acceptsMessage(event, this.$iframe.contentWindow))
|
||||
if (this.destroyed || !acceptsMessage(event, this.$iframe.contentWindow) || consumeNavigation(this, event.data))
|
||||
return
|
||||
|
||||
const { type, data, id } = event.data
|
||||
@@ -134,6 +137,7 @@ export default class ArtplayerToolIframe {
|
||||
}
|
||||
|
||||
postMessage(message: Packet): Promise<any> {
|
||||
prepareNavigation(this)
|
||||
return postRequest(this, message)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { Packet, RequestHost } from './requests'
|
||||
import { sessionMetadata, sessionType, withSession } from './protocol'
|
||||
import { cancelRequests, captureRequests, setRequestBoundary } from './requests'
|
||||
|
||||
interface Navigation {
|
||||
source: string
|
||||
rawSource: string
|
||||
documentId?: string
|
||||
hasHandshake: boolean
|
||||
waiting: boolean
|
||||
observer?: MutationObserver
|
||||
cancelPrevious?: (reason: unknown) => void
|
||||
}
|
||||
|
||||
const navigations = new WeakMap<RequestHost, Navigation>()
|
||||
const withoutHash = (value: string) => value.split('#')[0]!
|
||||
function sourceOf(host: RequestHost): string {
|
||||
const frame = host.$iframe
|
||||
return frame.hasAttribute?.('srcdoc') ? `srcdoc:${frame.getAttribute('srcdoc')}` : `src:${withoutHash(frame.src || '')}`
|
||||
}
|
||||
|
||||
function rawSourceOf(host: RequestHost): string {
|
||||
const frame = host.$iframe
|
||||
return frame.hasAttribute?.('srcdoc') ? `srcdoc:${frame.getAttribute('srcdoc')}` : `src:${frame.getAttribute?.('src') ?? frame.src ?? ''}`
|
||||
}
|
||||
|
||||
function invalidate(host: RequestHost, state: Navigation): void {
|
||||
if (!state.waiting) {
|
||||
if (state.documentId)
|
||||
state.cancelPrevious = captureRequests(host)
|
||||
else
|
||||
cancelRequests(host, new Error('The iframe document has changed'))
|
||||
}
|
||||
state.waiting = true
|
||||
host.injected = false
|
||||
}
|
||||
|
||||
function leaveDocument(host: RequestHost, state: Navigation): void {
|
||||
if (state.cancelPrevious)
|
||||
state.cancelPrevious(new Error('The iframe document has changed'))
|
||||
else if (!state.waiting)
|
||||
cancelRequests(host, new Error('The iframe document has changed'))
|
||||
state.cancelPrevious = undefined
|
||||
state.waiting = true
|
||||
host.injected = false
|
||||
}
|
||||
|
||||
function sourceChanged(host: RequestHost, state: Navigation, records: MutationRecord[]): boolean {
|
||||
if (sourceOf(host) !== state.source)
|
||||
return true
|
||||
if (!state.documentId)
|
||||
return false
|
||||
if (rawSourceOf(host) !== state.rawSource)
|
||||
return true
|
||||
return records.some((record) => {
|
||||
if (record.attributeName === 'srcdoc')
|
||||
return true
|
||||
if (record.attributeName !== 'src' || host.$iframe.hasAttribute?.('srcdoc'))
|
||||
return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function observeSource(host: RequestHost, state: Navigation, records: MutationRecord[]): void {
|
||||
const rawSource = rawSourceOf(host)
|
||||
if (!records.length && rawSource === state.rawSource)
|
||||
return
|
||||
const changed = sourceChanged(host, state, records)
|
||||
state.rawSource = rawSource
|
||||
state.source = sourceOf(host)
|
||||
if (changed)
|
||||
invalidate(host, state)
|
||||
}
|
||||
|
||||
export function prepareNavigation(host: RequestHost): void {
|
||||
const state = navigations.get(host)
|
||||
if (!state || host.destroyed)
|
||||
return
|
||||
const records = state.observer?.takeRecords() || []
|
||||
observeSource(host, state, records)
|
||||
}
|
||||
|
||||
export function createNavigation(host: RequestHost): { start: () => void, dispose: () => void } {
|
||||
const state: Navigation = { source: sourceOf(host), rawSource: rawSourceOf(host), hasHandshake: false, waiting: false }
|
||||
navigations.set(host, state)
|
||||
setRequestBoundary(host, {
|
||||
prepare: () => prepareNavigation(host),
|
||||
envelope: packet => state.documentId ? withSession(packet, state.documentId, 'message') : packet,
|
||||
})
|
||||
return {
|
||||
start() {
|
||||
if (typeof window.MutationObserver !== 'function')
|
||||
return
|
||||
state.observer = new window.MutationObserver((records) => {
|
||||
if (host.destroyed)
|
||||
return
|
||||
observeSource(host, state, records)
|
||||
})
|
||||
state.observer.observe(host.$iframe, { attributes: true, attributeOldValue: true, attributeFilter: ['src', 'srcdoc'] })
|
||||
},
|
||||
dispose() {
|
||||
navigations.delete(host)
|
||||
setRequestBoundary(host, undefined)
|
||||
state.cancelPrevious = undefined
|
||||
state.observer?.disconnect()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function activate(host: RequestHost, state: Navigation, documentId: string): void {
|
||||
if (state.hasHandshake && state.documentId !== documentId) {
|
||||
if (state.cancelPrevious)
|
||||
state.cancelPrevious(new Error('The iframe document has changed'))
|
||||
else if (!state.waiting)
|
||||
cancelRequests(host, new Error('The iframe document has changed'))
|
||||
}
|
||||
state.cancelPrevious = undefined
|
||||
state.documentId = documentId
|
||||
state.hasHandshake = true
|
||||
state.waiting = false
|
||||
host.injected = true
|
||||
try {
|
||||
host.$iframe.contentWindow?.postMessage(withSession({ type: sessionType, data: undefined, id: 0 }, documentId, 'ack'), '*')
|
||||
}
|
||||
catch {
|
||||
// A failed private acknowledgement does not replace the public inject callback.
|
||||
}
|
||||
}
|
||||
|
||||
export function consumeNavigation(host: RequestHost, packet: Packet): boolean {
|
||||
const state = navigations.get(host)
|
||||
if (!state)
|
||||
return false
|
||||
prepareNavigation(host)
|
||||
const metadata = sessionMetadata(packet)
|
||||
if (metadata && packet.type === sessionType) {
|
||||
if (metadata.phase === 'resume') {
|
||||
activate(host, state, metadata.document)
|
||||
}
|
||||
else if (metadata.document === state.documentId) {
|
||||
if (metadata.phase === 'leave')
|
||||
leaveDocument(host, state)
|
||||
else if (metadata.phase === 'fragment' && state.cancelPrevious && !host.$iframe.hasAttribute?.('srcdoc') && packet.data === host.$iframe.src)
|
||||
activate(host, state, metadata.document)
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (packet.type === 'inject') {
|
||||
if (metadata?.phase === 'inject') {
|
||||
if (state.waiting && metadata.document === state.documentId)
|
||||
return true
|
||||
activate(host, state, metadata.document)
|
||||
}
|
||||
else {
|
||||
if (state.waiting) {
|
||||
state.cancelPrevious?.(new Error('The iframe document has changed'))
|
||||
state.cancelPrevious = undefined
|
||||
state.documentId = undefined
|
||||
}
|
||||
state.hasHandshake = true
|
||||
state.waiting = false
|
||||
}
|
||||
return false
|
||||
}
|
||||
return Boolean(metadata && ((state.waiting && !state.cancelPrevious) || metadata.document !== state.documentId))
|
||||
}
|
||||
@@ -1,5 +1,27 @@
|
||||
import type { Packet } from './requests'
|
||||
|
||||
export const sessionType = 'artplayer-tool-iframe:session'
|
||||
export type SessionPhase = 'inject' | 'message' | 'ack' | 'leave' | 'resume' | 'fragment'
|
||||
export interface SessionMetadata {
|
||||
version: 1
|
||||
document: string
|
||||
phase: SessionPhase
|
||||
}
|
||||
|
||||
interface SessionPacket extends Packet {
|
||||
__artplayerIframe: SessionMetadata
|
||||
}
|
||||
|
||||
export function sessionMetadata(packet: Packet): SessionMetadata | undefined {
|
||||
const value = (packet as Partial<SessionPacket>).__artplayerIframe
|
||||
if (value?.version === 1 && typeof value.document === 'string' && value.document && ['inject', 'message', 'ack', 'leave', 'resume', 'fragment'].includes(value.phase))
|
||||
return value
|
||||
}
|
||||
|
||||
export function withSession(packet: Packet, documentId: string, phase: SessionPhase): SessionPacket {
|
||||
return { ...packet, __artplayerIframe: { version: 1, document: documentId, phase } }
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -20,11 +20,24 @@ interface Pending {
|
||||
id?: number
|
||||
timer?: ReturnType<typeof setTimeout>
|
||||
callbacks: Callbacks
|
||||
cancel: (reason: unknown) => void
|
||||
}
|
||||
|
||||
const requests = new WeakMap<RequestHost, Set<Pending>>()
|
||||
interface RequestBoundary {
|
||||
prepare: () => void
|
||||
envelope: (packet: Packet) => Packet
|
||||
}
|
||||
const boundaries = new WeakMap<RequestHost, RequestBoundary>()
|
||||
let lastId = 0
|
||||
|
||||
export function setRequestBoundary(host: RequestHost, boundary: RequestBoundary | undefined): void {
|
||||
if (boundary)
|
||||
boundaries.set(host, boundary)
|
||||
else
|
||||
boundaries.delete(host)
|
||||
}
|
||||
|
||||
export function postRequest(host: RequestHost, { type, data }: Packet): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (host.destroyed) {
|
||||
@@ -55,6 +68,10 @@ export function postRequest(host: RequestHost, { type, data }: Packet): Promise<
|
||||
reject(error)
|
||||
},
|
||||
},
|
||||
cancel(error) {
|
||||
if (finish(request))
|
||||
reject(error)
|
||||
},
|
||||
}
|
||||
pending.add(request)
|
||||
const loop = () => {
|
||||
@@ -62,33 +79,45 @@ export function postRequest(host: RequestHost, { type, data }: Packet): Promise<
|
||||
if (!pending.has(request))
|
||||
return
|
||||
if (host.destroyed) {
|
||||
request.callbacks.reject(new Error('The instance has been destroyed'))
|
||||
request.cancel(new Error('The instance has been destroyed'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
boundaries.get(host)?.prepare()
|
||||
if (!pending.has(request))
|
||||
return
|
||||
if (host.injected) {
|
||||
const id = Math.max(Date.now(), lastId + 1)
|
||||
lastId = id
|
||||
request.id = id
|
||||
host.promises[id] = request.callbacks
|
||||
host.$iframe.contentWindow!.postMessage({ type, data, id }, '*')
|
||||
const packet = { type, data, id }
|
||||
host.$iframe.contentWindow!.postMessage(boundaries.get(host)?.envelope(packet) || packet, '*')
|
||||
}
|
||||
else {
|
||||
request.timer = setTimeout(loop, 200)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
request.callbacks.reject(error)
|
||||
request.cancel(error)
|
||||
}
|
||||
}
|
||||
loop()
|
||||
})
|
||||
}
|
||||
|
||||
export function cancelRequests(host: RequestHost): void {
|
||||
export function cancelRequests(host: RequestHost, reason = new Error('The instance has been destroyed')): void {
|
||||
const pending = requests.get(host)
|
||||
if (!pending)
|
||||
return
|
||||
for (const request of [...pending])
|
||||
request.callbacks.reject(new Error('The instance has been destroyed'))
|
||||
request.cancel(reason)
|
||||
}
|
||||
|
||||
export function captureRequests(host: RequestHost): (reason: unknown) => void {
|
||||
const captured = [...(requests.get(host) || [])]
|
||||
return (reason) => {
|
||||
for (const request of captured)
|
||||
request.cancel(reason)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,78 @@
|
||||
# PKG-IFRAME-03 document ownership and internal structure
|
||||
|
||||
The public class now delegates connection cleanup, request ownership, envelope
|
||||
admission, parent navigation and child lifecycle to six strict TS modules.
|
||||
Requests receive an internal boundary hook; child and parent session code use
|
||||
the shared envelope helpers without importing the public class or core. The
|
||||
module map and ownership/maintenance commands are in the package architecture.
|
||||
Earlier request and peer-boundary checkpoints remain separate evidence.
|
||||
|
||||
## Compatibility decision and defect changes
|
||||
|
||||
ADR-026 adds optional document metadata to the existing type/data/id protocol.
|
||||
Normal new/old wire combinations preserve the public callback data and receiver,
|
||||
constructor/field descriptors, resove spelling, generic responses and the old
|
||||
function-body/resolve commit convention. New private lifecycle traffic starts
|
||||
only after acknowledgement and is not forwarded through the public message API.
|
||||
An unchanged peer remains on the legacy path; its limitations are explicit.
|
||||
|
||||
Document departure now rejects owned work with `The iframe document has changed`.
|
||||
The code pauses new sends while replacing a source, captures only the old request
|
||||
set, and preserves requests queued for the replacement. Old pagehide/repeated
|
||||
inject cannot cancel or activate the replacement's queue. Different-document
|
||||
responses/notifications and commands are filtered. Destroy still owns all remaining
|
||||
requests even if consumers replaced/deleted their public callback records.
|
||||
|
||||
The first source matrix passed 342, but an independent fragment review found a
|
||||
real regression in three Chromium cases: the document witness was unchanged while
|
||||
the candidate rejected its request. A source setter is not proof of document
|
||||
departure. The final design captures requests provisionally and waits for pagehide
|
||||
or another document marker before cancelling; a matching native hashchange report
|
||||
preserves same-document work. The failing report is retained. Raw attribute
|
||||
tracking also avoids inventing navigation when only a src getter's base resolution
|
||||
changes. These repairs are not made by weakening the document-identity assertions.
|
||||
|
||||
A later ownership review reproduced another candidate defect: repeated source
|
||||
mutations recaptured and cancelled requests queued after navigation began. The
|
||||
snapshot now belongs to the departing document and is captured once. Unsent work
|
||||
waits for the final document to inject, including when an intermediate target is
|
||||
superseded. The browser rapid-navigation expectation was corrected to this explicit
|
||||
contract; a separate Node regression proves both before/after-pagehide orderings.
|
||||
|
||||
## Evidence and limits
|
||||
|
||||
The navigation tests include marked and legacy peers, synchronous post after src,
|
||||
rapid changes, source/srcdoc priority, repeated injection, inject-before-load,
|
||||
same-address reload, native fragment transitions, old packet replay, history and
|
||||
listener/observer cleanup. A controlled load barrier proves the inject-before-load
|
||||
order. Browser history records actual pageshow.persisted rather than treating all
|
||||
history navigation as cached restoration. Stale-packet cases replay old metadata
|
||||
from owned fixtures to isolate routing; they are not a claim of cross-site access.
|
||||
|
||||
Final counts, source/artifact hashes, frozen preceding-build association and
|
||||
reports are in `baselines/iframe-navigation-validation.json`. Public declaration
|
||||
reconciliation remains PKG-IFRAME-04; full old/new core/demo integration, actual
|
||||
BFCache/device and interrupted navigation evidence remain PKG-IFRAME-05/review.
|
||||
The corresponding risks remain open with that owner; no distribution or release
|
||||
gate is silently closed. No dependency, version, package manager, push or publish.
|
||||
|
||||
Rollback: revert this task completion and rebuild artplayer-tool-iframe normally.
|
||||
Earlier frozen npm/Git evidence remains intact. Dedicated commit subject:
|
||||
`refactor(iframe): [PKG-IFRAME-03] scope requests to active documents`.
|
||||
|
||||
Final source/main/legacy each pass 50 Node assertions (16 navigation, 18 boundary,
|
||||
16 request lifecycle). Native browsers pass source 351, main 351 and legacy 207;
|
||||
each includes 117 navigation/legacy-child, 60 boundary/mixed and 30 lifecycle cases.
|
||||
Source/main additionally preserve 144 historical defect assertions. Final matrices
|
||||
have no skips, automatic retries/flakes or unhandled browser errors. The preceding
|
||||
legacy run was 206 pass/1 Firefox context-close teardown timeout, after all product
|
||||
assertions, destroy/frame removal and diagnostics had completed in the trace.
|
||||
No root cause or fix is claimed: the unchanged case passed ten focused repetitions,
|
||||
then the unchanged full legacy matrix passed. Original trace/report remain archived;
|
||||
IFRAME-05/release review retain harness reproducibility if the symptom recurs.
|
||||
History details remain in the evidence;
|
||||
ordinary reloads are not BFCache. Full CI passes 1539 (1365 unit, 14 engineering,
|
||||
160 baseline), 44 repeated contracts and 330 production TS files. Three import/SSR
|
||||
files pass. Source, artifact, report and preceding Git hashes plus all three docs
|
||||
copies are verified. PKG-IFRAME-03 is done; 04/05/06 and the three iframe risks
|
||||
remain open. No package version or publication gate is advanced by this task.
|
||||
@@ -32,6 +32,10 @@
|
||||
|
||||
## 开始实现前需补齐的决定
|
||||
|
||||
ADR-026:Iframe采用可选文档标记与两阶段导航归属,保留旧端wire和公开回调;
|
||||
原生片段跳转不取消同文档请求。协议、拒绝时机和未升级端/真实BFCache的边界见
|
||||
[文档协议](iframe-document-protocol.md)。
|
||||
|
||||
ADR-025:Iframe绑定实际父子窗口,保留跨源/重定向/opaque-origin与公开local receiver,
|
||||
同时拒绝无关窗口和无效消息。独立依据、兼容影响与剩余信任限制见
|
||||
[Iframe消息边界](iframe-message-boundary.md);本轮不将来源绑定视为导航或全包发布验收。
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# ADR-026:兼容的文档标记与导航归属
|
||||
|
||||
关联PKG-IFRAME-03/05;2026-09-13。实现与验证以本轮记录为准。
|
||||
|
||||
## 问题与取舍
|
||||
|
||||
WindowProxy在导航中保持身份。旧协议只用Date.now的请求ID,无法区分某个inject、
|
||||
响应或UI通知属于哪个子文档。直接在iframe load时清空状态也不正确:实际子页可以
|
||||
先inject,再等待图片等资源完成load。只观察src属性同样不足,片段跳转不会更换文档。
|
||||
|
||||
本轮先采用直接属性取消的候选,真实Chromium三个attribute-fragment场景证明它误拒绝
|
||||
同文档请求。修复使用“导航意图—确认离开”两个阶段;失败报告保留,不靠放宽断言通过。
|
||||
不更换公开传输为另一套RPC,不要求旧页面改写commit函数,也不把WindowProxy当作文档ID。
|
||||
|
||||
## 可选扩展与旧端兼容
|
||||
|
||||
公开type/data/id、任意非error同ID响应、message({type,data})回调及其this保持原样。
|
||||
新增内部可选字段`__artplayerIframe`,包含version=1、document字符串和phase。
|
||||
文档标记是关联标识,不是秘密或认证令牌;来源信任仍按ADR-025。
|
||||
|
||||
| 消息 | 行为 |
|
||||
| --- | --- |
|
||||
| inject | 新子页在原inject封套上附文档标记;旧父页仍只读取原三个字段 |
|
||||
| ack | 新父页识别标记后,使用带标记的`artplayer-tool-iframe:session`确认;不进入公开message回调 |
|
||||
| message | 确认后,子页普通postMessage/响应带文档标记;父页发出的请求也标注目标文档 |
|
||||
| leave | 已确认子页在原生pagehide发送私有通知,父页取消原文档所属请求 |
|
||||
| resume | 原生pageshow.persisted恢复时通知;不伪造普通inject回调 |
|
||||
| fragment | 原生hashchange报告实际location.href;匹配父页目标src时完成同文档跳转 |
|
||||
|
||||
没有新父页ack时,新子页的普通消息保持旧封套,也不发新的私有生命周期通知。
|
||||
没有子页文档标记时,新父页继续使用旧封套。实际npm主类与初始工作区主类在父/子
|
||||
两侧均有正常wire组合;额外helper的不同协议仍由IFRAME-04/06单独处理。
|
||||
原生window监听器会看见可选字段和已协商的私有控制流;工具的公开message回调不暴露私有控制包。
|
||||
|
||||
## 请求与导航状态
|
||||
|
||||
1. 监听src/srcdoc属性;公开postMessage入口和等待轮询也消费尚未送达的MutationObserver记录。
|
||||
2. 已标记的文档开始导航时,捕获当时仍属于它的请求集合,暂停后续发送。新请求排队等待。
|
||||
3. pagehide或另一个文档inject确认换页后,只取消捕获的旧集合;之后排队的新请求保留。
|
||||
在任何新文档注入前连续修改目标,只更新目标,不重新捕获排队请求;这些尚未发送的请求交给最终注入的文档。
|
||||
4. 片段跳转仍在同一文档:hashchange报告的完整URL必须匹配实际目标src,释放暂停并保留旧请求。
|
||||
不把src相对/绝对写法或重新设置片段属性误当作新文档;不以load事件猜测文档身份。
|
||||
5. 不同标记的迟到响应/通知被忽略,发给旧标记的commit不会在新子页执行。
|
||||
6. destroy解除监听、observer、请求边界和捕获集合;立即拒绝仍归属实例的全部请求。
|
||||
|
||||
文档确认离开时的Error消息为`The iframe document has changed`;destroy沿用
|
||||
`The instance has been destroyed`。调用方需要处理commit/postMessage的拒绝。
|
||||
未确认离开前,原文档已完成的响应仍可结算原请求;这属于活文档的正常完成,不是假定所有导航意图都是换页。
|
||||
|
||||
公开promises记录保留resove/reject和对象身份。普通响应仍尊重用户替换的公开回调;
|
||||
内部取消独立持有原Promise拒绝入口,不能因用户删除/替换记录而泄漏原Promise。
|
||||
|
||||
## 明确限制与后续门槛
|
||||
|
||||
- 旧端没有文档标记;不同有效源地址的属性导航可以管理,但同地址重载、子页内部导航和文档级迟到过滤
|
||||
不能通过未升级的一侧可靠证明。完整文档保护需双方使用新实现,旧正常协议继续兼容。
|
||||
- MutationObserver不可用时,只能在公开方法/轮询时检查可观察的源变化;语法legacy目标不等于补齐DOM能力。
|
||||
- 未标记的自定义原生消息仍按旧协议接受;其文档级归属由应用管理。SDK提供的消息在协商后携带标记。
|
||||
- 历史返回用例记录实际pageshow.persisted。普通重新加载不能计作原生BFCache验证;完整播放器、
|
||||
原生BFCache/设备、外部中断导航与文档站实际组合仍在IFRAME-05及发布复盘核验,未由本轮自动豁免。
|
||||
- 本协议不验证父页origin许可,也不把可执行commit变成代码沙箱;嵌入父页和选定子内容仍需可信。
|
||||
|
||||
验证入口为test/iframe-navigation.test.js和test/browser/iframe-navigation.spec.js,记录见
|
||||
baselines/iframe-navigation-validation.json。回退本轮源码后使用正常package build重建,不手改产物。
|
||||
+4
-4
@@ -4,7 +4,7 @@
|
||||
|
||||
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 222 项,范围 22 个包及工作区/示例。
|
||||
|
||||
状态:todo 105 / doing 10 / blocked 0 / done 107 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
|
||||
状态:todo 105 / doing 9 / blocked 0 / done 108 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
|
||||
|
||||
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
|
||||
|
||||
@@ -346,9 +346,9 @@
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| PKG-IFRAME-01 | artplayer-tool-iframe<br>核对包契约与历史用法 | BASE-05 | constructor/commit/message/inject、postMessage 协议与历史公开拼写 | 源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结 | H | done |
|
||||
| PKG-IFRAME-02 | artplayer-tool-iframe<br>建立特有行为与错误测试 | PKG-IFRAME-01, ENG-03, ENG-05 | 跨窗口消息、ID 匹配、请求失败、重复 inject、销毁中请求 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | H | done |
|
||||
| PKG-IFRAME-03 | artplayer-tool-iframe<br>整理内部职责与资源 | PKG-IFRAME-02, CORE-02, BASE-07 | 请求注册/响应匹配/监听清理分离;origin/source 安全边界独立决策 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | doing |
|
||||
| PKG-IFRAME-03 | artplayer-tool-iframe<br>整理内部职责与资源 | PKG-IFRAME-02, CORE-02, BASE-07 | 请求注册/响应匹配/监听清理分离;origin/source 安全边界独立决策 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | done |
|
||||
| PKG-IFRAME-04 | artplayer-tool-iframe<br>迁移自有源码和公开类型 | PKG-IFRAME-03, ENG-04, ENG-06, CORE-07 | 消息联合类型、回调/Promise 推导、旧公开字段兼容;旧npm export=与实际namespace、额外helper协议分别核验 | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容 | H | todo |
|
||||
| PKG-IFRAME-05 | artplayer-tool-iframe<br>验证新旧核心和组合 | PKG-IFRAME-04, CORE-22 | 真实同源/跨源 iframe、既有 commit 协议;安全变化有独立结论 | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | H | todo |
|
||||
| PKG-IFRAME-05 | artplayer-tool-iframe<br>验证新旧核心和组合 | PKG-IFRAME-04, CORE-22 | 真实同源/跨源 iframe、既有 commit 协议;安全变化有独立结论;新旧核心/demo、实际 BFCache/设备和外部中断导航验收,旧端无文档标记的限制明确 | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | H | todo |
|
||||
| PKG-IFRAME-06 | artplayer-tool-iframe<br>验证分发并同步文档 | PKG-IFRAME-05, ENG-07 | iframe.js、示例集成和原 script/class 导出验证;旧包名/额外helper深入口与新工具名的迁移结论 | tarball 入口/资源、类型、8082 demo 和 README 一致,有回退记录 | H | todo |
|
||||
|
||||
## 5 包迁移:artplayer-tool-thumbnail
|
||||
@@ -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) [记录](changes/2026-09-12-PKG-IFRAME-03-boundaries-checkpoint.md) [记录](baselines/iframe-boundaries-checkpoint.json) [记录](iframe-message-boundary.md)
|
||||
- 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) [记录](changes/2026-09-13-PKG-IFRAME-03-navigation.md) [记录](baselines/iframe-navigation-validation.json) [记录](iframe-document-protocol.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 完成:按文档管理请求与六模块拆分
|
||||
|
||||
入口、连接、请求、消息边界、父页导航和子页生命周期拆为六个严格TS模块。
|
||||
保留公开字段/回调/resove和旧协议;协商文档标记用于换页取消、迟到消息过滤,
|
||||
连续切源只取消旧文档请求,未发送队列等待最终注入。实际片段导航回归和重复捕获
|
||||
请求问题均先复现后修复,失败报告保留。ADR-026说明两阶段导航及旧端能力限制。
|
||||
源码/main/legacy各50项Node通过;真实三引擎分别351/351/207通过,无跳过。
|
||||
每份含117导航/旧子页、60边界/互通和30生命周期,source/main另含144历史断言。
|
||||
完整CI1539与44重复契约、3导入/SSR通过,330生产TS;产物/docs/报告哈希核对。
|
||||
见[决策](iframe-document-protocol.md)、[变更](changes/2026-09-13-PKG-IFRAME-03-navigation.md)
|
||||
和[验证](baselines/iframe-navigation-validation.json)。222项:108 done、9 doing、105 todo。
|
||||
IFRAME三个风险仍open:04公开声明/旧消费,05完整播放器/demo、实际BFCache/设备和
|
||||
外部中断导航,06分发仍需完成。本项专用本地提交,不推送或发布。
|
||||
|
||||
## PKG-IFRAME-03 构造清理与窗口来源检查点(doing)
|
||||
|
||||
增加connection/protocol两个严格TS模块;构造失败清理监听和重入请求,destroy即使
|
||||
|
||||
@@ -174,6 +174,6 @@
|
||||
| MB-CAP-01 | open / 已复现 | Current Windows WebKit lacks WebCodecs and Web Audio constructors needed by historical MediaBunny proxies | PKG-MB-02, PKG-MB-09 |
|
||||
| MB-READY-01 | resolved / 已复现 | MediaBunny historical readiness can repeat for trackless input and occur after capability errors | PKG-MB-04, PKG-MB-09 |
|
||||
| DPIP-DISPLAY-01 | resolved / 已复现 | Web fullscreen body placement reclaims a player adopted into another document | CORE-22, PKG-DPIP-05 |
|
||||
| IFRAME-LIFE-01 | open / 已复现 | Iframe timestamp IDs and unowned pending requests/timers lack terminal cleanup | PKG-IFRAME-02, PKG-IFRAME-03 |
|
||||
| IFRAME-LIFE-01 | open / 已复现 | Iframe timestamp IDs and unowned pending requests/timers lack terminal cleanup | PKG-IFRAME-02, PKG-IFRAME-03, PKG-IFRAME-05 |
|
||||
| IFRAME-TRUST-01 | open / 已复现 | Iframe executable commit protocol accepts messages without source/origin validation | PKG-IFRAME-03, PKG-IFRAME-05 |
|
||||
| IFRAME-DIST-01 | open / 已复现 | Iframe tool name is unavailable at registry observation and old archive includes distinct helper exports | PKG-IFRAME-04, PKG-IFRAME-06 |
|
||||
+12
-5
@@ -3771,11 +3771,12 @@
|
||||
"title": "Iframe timestamp IDs and unowned pending requests/timers lack terminal cleanup",
|
||||
"owners": [
|
||||
"PKG-IFRAME-02",
|
||||
"PKG-IFRAME-03"
|
||||
"PKG-IFRAME-03",
|
||||
"PKG-IFRAME-05"
|
||||
],
|
||||
"status": "open",
|
||||
"confirmation": "reproduced",
|
||||
"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.",
|
||||
"compatibleResolution": "Owned requests and cleanup cover terminal failures. ADR-026 adds document-scoped cancellation, provisional source changes, same-document fragment preservation and queued final navigation. Keep open for PKG-IFRAME-05 full player/demo, actual BFCache/device and interrupted-navigation acceptance; unchanged legacy peers have documented document-identity limits.",
|
||||
"closureCriteria": "Old behavior and candidate fixes have deterministic race/resource tests plus actual iframe integration.",
|
||||
"evidence": [
|
||||
"refactor/baselines/iframe-contract.md",
|
||||
@@ -3786,7 +3787,10 @@
|
||||
"refactor/changes/2026-09-12-PKG-IFRAME-03-requests-checkpoint.md",
|
||||
"refactor/baselines/iframe-requests-checkpoint.json",
|
||||
"refactor/changes/2026-09-12-PKG-IFRAME-03-boundaries-checkpoint.md",
|
||||
"refactor/baselines/iframe-boundaries-checkpoint.json"
|
||||
"refactor/baselines/iframe-boundaries-checkpoint.json",
|
||||
"refactor/changes/2026-09-13-PKG-IFRAME-03-navigation.md",
|
||||
"refactor/baselines/iframe-navigation-validation.json",
|
||||
"refactor/iframe-document-protocol.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -3798,7 +3802,7 @@
|
||||
],
|
||||
"status": "open",
|
||||
"confirmation": "reproduced",
|
||||
"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.",
|
||||
"compatibleResolution": "ADR-025 binds actual native peers; ADR-026 correlates negotiated document messages without breaking normal legacy wire. This is not an origin allowlist or commit sandbox. Keep open for PKG-IFRAME-05 complete integration/release review and documented legacy/custom unmarked-message limits.",
|
||||
"closureCriteria": "Controlled and real same/cross-origin evidence plus an explicit documented compatibility decision and supported trust model.",
|
||||
"evidence": [
|
||||
"refactor/baselines/iframe-contract.md",
|
||||
@@ -3808,7 +3812,10 @@
|
||||
"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"
|
||||
"refactor/iframe-message-boundary.md",
|
||||
"refactor/changes/2026-09-13-PKG-IFRAME-03-navigation.md",
|
||||
"refactor/baselines/iframe-navigation-validation.json",
|
||||
"refactor/iframe-document-protocol.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+6
-3
@@ -3656,7 +3656,7 @@
|
||||
"CORE-02",
|
||||
"BASE-07"
|
||||
],
|
||||
"status": "doing",
|
||||
"status": "done",
|
||||
"risk": "H",
|
||||
"deliverable": "请求注册/响应匹配/监听清理分离;origin/source 安全边界独立决策",
|
||||
"acceptance": "结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过",
|
||||
@@ -3665,7 +3665,10 @@
|
||||
"baselines/iframe-requests-checkpoint.json",
|
||||
"changes/2026-09-12-PKG-IFRAME-03-boundaries-checkpoint.md",
|
||||
"baselines/iframe-boundaries-checkpoint.json",
|
||||
"iframe-message-boundary.md"
|
||||
"iframe-message-boundary.md",
|
||||
"changes/2026-09-13-PKG-IFRAME-03-navigation.md",
|
||||
"baselines/iframe-navigation-validation.json",
|
||||
"iframe-document-protocol.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -3700,7 +3703,7 @@
|
||||
],
|
||||
"status": "todo",
|
||||
"risk": "H",
|
||||
"deliverable": "真实同源/跨源 iframe、既有 commit 协议;安全变化有独立结论",
|
||||
"deliverable": "真实同源/跨源 iframe、既有 commit 协议;安全变化有独立结论;新旧核心/demo、实际 BFCache/设备和外部中断导航验收,旧端无文档标记的限制明确",
|
||||
"acceptance": "最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成",
|
||||
"evidence": []
|
||||
},
|
||||
|
||||
@@ -116,7 +116,7 @@ for (const item of cases) {
|
||||
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)
|
||||
await expect.poll(() => child.evaluate(() => window.received.filter(packet => !packet.internal).length)).toBe(6)
|
||||
const value = await page.evaluate(() => window.tool.commit(() => {
|
||||
return 12
|
||||
}))
|
||||
|
||||
@@ -5,11 +5,22 @@
|
||||
window.Tool = window[parameters.get('global')];
|
||||
window.received = [];
|
||||
window.executions = 0;
|
||||
window.documentWitness = Math.random().toString(36);
|
||||
addEventListener('pagehide', event => parent.postMessage({ type: 'fixture-pagehide', data: { epoch: parameters.get('epoch'), persisted: event.persisted } }, '*'));
|
||||
addEventListener('pageshow', event => parent.postMessage({ type: 'fixture-pageshow', data: { epoch: parameters.get('epoch'), persisted: event.persisted, witness: window.documentWitness } }, '*'));
|
||||
addEventListener('message', event => {
|
||||
received.push({ type: event.data?.type, id: event.data?.id, fromParent: event.source === parent, origin: event.origin });
|
||||
received.push({ type: event.data?.type, id: event.data?.id, fromParent: event.source === parent, origin: event.origin, internal: event.data?.type === 'artplayer-tool-iframe:session' && event.data?.__artplayerIframe?.version === 1 });
|
||||
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>
|
||||
<body>
|
||||
<script>
|
||||
if (parameters.get('barrier') === '1') {
|
||||
const image = document.createElement('img');
|
||||
image.src = '/iframe-load-barrier.svg';
|
||||
document.body.append(image);
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,291 @@
|
||||
import fs from 'node:fs'
|
||||
import { hash } from '../../refactor/scripts/releases.mjs'
|
||||
import { iframeCandidate, iframeHistorical } from '../helpers/iframe.js'
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
const implementation = await iframeCandidate()
|
||||
const scenarios = ['inject-before-load', 'duplicate-inject', 'attribute-navigation', 'child-navigation', 'rapid-navigation', 'same-source-reload', 'fragment-navigation', 'attribute-fragment', 'stale-packets', 'history-back', 'srcdoc-navigation']
|
||||
const childHtml = fs.readFileSync(new URL('./iframe-boundary-child.html', import.meta.url), 'utf8')
|
||||
|
||||
for (const relation of ['same-origin', 'cross-origin', 'opaque-origin']) {
|
||||
for (const scenario of scenarios) {
|
||||
test(`Iframe document ${relation}/${scenario}/${implementation.name}`, async ({ page }, testInfo) => {
|
||||
await page.goto('/test/player.html?core=published')
|
||||
const parentOrigin = new URL(page.url()).origin
|
||||
const childOrigin = relation === 'cross-origin' ? parentOrigin.replace('127.0.0.1', 'localhost') : parentOrigin
|
||||
const urlFor = (epoch, auto = true) => `${childOrigin}/test/iframe-boundary-child.html?global=${implementation.global}&auto=${auto ? 1 : 0}&epoch=${epoch}`
|
||||
const url = urlFor(1) + (scenario === 'inject-before-load' ? '&barrier=1' : '')
|
||||
let releaseBarrier
|
||||
await page.route('**/iframe-load-barrier.svg', async (route) => {
|
||||
await new Promise(resolve => releaseBarrier = resolve)
|
||||
await route.fulfill({ contentType: 'image/svg+xml', body: '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>' })
|
||||
})
|
||||
await page.route('**/iframe-boundary-lib.js', route => route.fulfill({ contentType: 'text/javascript', body: implementation.code }))
|
||||
await page.addScriptTag({ content: `(() => { const module = { exports: {} }; const exports = module.exports; ${implementation.code}; window.IframeFactory = module.exports.default || module.exports; })();` })
|
||||
await page.evaluate(({ url, opaque }) => {
|
||||
window.timeline = []
|
||||
window.states = {}
|
||||
window.callbacks = []
|
||||
const frame = document.createElement('iframe')
|
||||
if (opaque)
|
||||
frame.sandbox = 'allow-scripts'
|
||||
document.body.append(frame)
|
||||
window.targetFrame = frame
|
||||
frame.addEventListener('load', () => window.timeline.push({ kind: 'load' }))
|
||||
addEventListener('message', event => window.timeline.push({ kind: 'message', packet: event.data, fromChild: event.source === frame.contentWindow, origin: event.origin }))
|
||||
window.tool = new window.IframeFactory({ iframe: frame, url })
|
||||
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 })
|
||||
}
|
||||
}, { url: scenario === 'attribute-fragment' && relation === 'same-origin' ? url.replace(parentOrigin, '') : url, opaque: relation === 'opaque-origin' })
|
||||
let result
|
||||
try {
|
||||
await expect.poll(() => page.evaluate(() => window.tool.injected)).toBe(true)
|
||||
let child = page.frames().find(frame => frame.url() === url)
|
||||
const first = await page.evaluate(() => window.timeline.find(item => item.packet?.type === 'inject').packet.__artplayerIframe?.document || 'unmarked')
|
||||
if (first !== 'unmarked')
|
||||
await expect.poll(() => child.evaluate(() => window.received.some(packet => packet.internal))).toBe(true)
|
||||
const witness = await child.evaluate(() => window.documentWitness)
|
||||
await page.evaluate(() => window.track('old', window.tool.postMessage({ type: 'hold' })))
|
||||
await expect.poll(() => child.evaluate(() => Boolean(window.held))).toBe(true)
|
||||
const heldId = await child.evaluate(() => window.held.id)
|
||||
if (scenario === 'inject-before-load') {
|
||||
await expect.poll(() => Boolean(releaseBarrier)).toBe(true)
|
||||
expect(await page.evaluate(() => window.timeline.filter(item => item.kind === 'load').length)).toBe(0)
|
||||
releaseBarrier()
|
||||
await expect.poll(() => page.evaluate(() => window.timeline.some(item => item.kind === 'load'))).toBe(true)
|
||||
expect(await page.evaluate(() => window.states.old.status)).toBe('pending')
|
||||
expect(await page.evaluate(() => window.tool.injected)).toBe(true)
|
||||
await child.evaluate(() => window.Tool.postMessage({ type: 'response', id: window.held.id, data: 'after load' }))
|
||||
await expect.poll(() => page.evaluate(() => window.states.old.status)).toBe('resolved')
|
||||
result = await page.evaluate(() => ({ state: window.states.old, order: window.timeline.filter(item => item.kind === 'load' || item.packet?.type === 'inject').map(item => item.kind === 'load' ? 'load' : 'inject') }))
|
||||
expect(result.order).toEqual(['inject', 'load'])
|
||||
}
|
||||
else if (scenario === 'duplicate-inject') {
|
||||
await child.evaluate(() => {
|
||||
window.Tool.inject()
|
||||
window.Tool.inject()
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.timeline.filter(item => item.packet?.type === 'inject').length)).toBe(3)
|
||||
expect(await page.evaluate(() => window.states.old.status)).toBe('pending')
|
||||
await child.evaluate(() => window.Tool.postMessage({ type: 'response', id: window.held.id, data: 12 }))
|
||||
await expect.poll(() => page.evaluate(() => window.states.old.status)).toBe('resolved')
|
||||
result = await page.evaluate(() => ({ state: window.states.old, documentIds: window.timeline.filter(item => item.packet?.type === 'inject').map(item => item.packet.__artplayerIframe?.document || 'unmarked') }))
|
||||
expect(result.documentIds.every(value => value === first)).toBe(true)
|
||||
}
|
||||
else if (scenario === 'fragment-navigation') {
|
||||
await child.evaluate(() => location.hash = 'chapter')
|
||||
await expect.poll(() => child.evaluate(() => location.hash)).toBe('#chapter')
|
||||
expect(await child.evaluate(() => window.documentWitness)).toBe(witness)
|
||||
await child.evaluate(() => window.Tool.postMessage({ type: 'response', id: window.held.id, data: 'same document' }))
|
||||
await expect.poll(() => page.evaluate(() => window.states.old.status)).toBe('resolved')
|
||||
result = await page.evaluate(() => ({ state: window.states.old, injected: window.tool.injected }))
|
||||
expect(result.injected).toBe(true)
|
||||
}
|
||||
else if (scenario === 'attribute-fragment') {
|
||||
await child.evaluate(() => location.hash = 'child')
|
||||
await page.evaluate(() => window.targetFrame.src = `${window.targetFrame.src.split('#')[0]}#parent`)
|
||||
await expect.poll(() => child.evaluate(() => location.hash)).toBe('#parent')
|
||||
expect(await child.evaluate(() => window.documentWitness)).toBe(witness)
|
||||
expect(await page.evaluate(() => window.states.old.status)).toBe('pending')
|
||||
await child.evaluate(() => location.hash = 'child-again')
|
||||
await page.evaluate(() => {
|
||||
const attribute = window.targetFrame.src
|
||||
window.targetFrame.src = attribute
|
||||
})
|
||||
await expect.poll(() => child.evaluate(() => location.hash)).toBe('#parent')
|
||||
expect(await child.evaluate(() => window.documentWitness)).toBe(witness)
|
||||
expect(await page.evaluate(() => window.states.old.status)).toBe('pending')
|
||||
await child.evaluate(() => window.Tool.postMessage({ type: 'response', id: window.held.id, data: 'attribute fragment' }))
|
||||
await expect.poll(() => page.evaluate(() => window.states.old.status)).toBe('resolved')
|
||||
result = await page.evaluate(() => ({ state: window.states.old, injected: window.tool.injected }))
|
||||
expect(result.injected).toBe(true)
|
||||
}
|
||||
else if (scenario === 'same-source-reload') {
|
||||
await page.evaluate(() => {
|
||||
const currentSource = window.targetFrame.src
|
||||
window.targetFrame.src = currentSource
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.timeline.filter(item => item.packet?.type === 'inject').length)).toBe(2)
|
||||
child = page.frames().find(frame => frame.url() === url)
|
||||
expect(await child.evaluate(() => window.documentWitness)).not.toBe(witness)
|
||||
await expect.poll(() => page.evaluate(() => window.states.old.status)).toBe('rejected')
|
||||
result = await page.evaluate(() => ({ state: window.states.old, documentIds: window.timeline.filter(item => item.packet?.type === 'inject').map(item => item.packet.__artplayerIframe.document) }))
|
||||
expect(result.documentIds[0]).not.toBe(result.documentIds[1])
|
||||
}
|
||||
else if (scenario === 'srcdoc-navigation') {
|
||||
const html = childHtml.replace('src="/iframe-boundary-lib.js"', `src="${parentOrigin}/iframe-boundary-lib.js"`).replace('new URL(location.href).searchParams', `new URLSearchParams('global=${implementation.global}&auto=0&epoch=srcdoc')`)
|
||||
await page.evaluate((html) => {
|
||||
window.targetFrame.srcdoc = html
|
||||
window.track('srcdoc', window.tool.commit(() => {
|
||||
return document.URL
|
||||
}))
|
||||
}, html)
|
||||
await expect.poll(() => page.frames().some(frame => frame.url() === 'about:srcdoc')).toBe(true)
|
||||
child = page.frames().find(frame => frame.url() === 'about:srcdoc')
|
||||
await expect.poll(() => child.evaluate(() => Boolean(window.Tool))).toBe(true)
|
||||
await expect.poll(() => page.evaluate(() => window.states.old.status)).toBe('rejected')
|
||||
expect(await page.evaluate(() => window.tool.injected)).toBe(false)
|
||||
await child.evaluate(() => window.Tool.inject())
|
||||
await expect.poll(() => page.evaluate(() => window.states.srcdoc.status)).toBe('resolved')
|
||||
expect(await page.evaluate(() => window.states.srcdoc.value)).toBe('about:srcdoc')
|
||||
await page.evaluate((url) => {
|
||||
window.track('srcdocHeld', window.tool.postMessage({ type: 'hold' }))
|
||||
window.targetFrame.src = url
|
||||
}, urlFor(2))
|
||||
await expect.poll(() => child.evaluate(() => Boolean(window.held))).toBe(true)
|
||||
expect(await page.evaluate(() => window.states.srcdocHeld.status)).toBe('pending')
|
||||
await page.evaluate(() => window.targetFrame.removeAttribute('srcdoc'))
|
||||
await expect.poll(() => page.frames().some(frame => frame.url() === urlFor(2))).toBe(true)
|
||||
await expect.poll(() => page.evaluate(() => window.states.srcdocHeld.status)).toBe('rejected')
|
||||
const value = await page.evaluate(() => window.tool.commit(() => {
|
||||
return new URL(location.href).searchParams.get('epoch')
|
||||
}))
|
||||
expect(value).toBe('2')
|
||||
result = await page.evaluate(() => ({ states: window.states, injected: window.tool.injected, pending: Object.keys(window.tool.promises).length }))
|
||||
expect(result.pending).toBe(0)
|
||||
}
|
||||
else {
|
||||
const next = urlFor(scenario === 'rapid-navigation' ? 3 : 2, false)
|
||||
if (scenario === 'child-navigation' || scenario === 'history-back') {
|
||||
await child.evaluate(url => location.href = url, next)
|
||||
}
|
||||
else {
|
||||
await page.evaluate(({ next, intermediate }) => {
|
||||
if (intermediate) {
|
||||
window.targetFrame.src = intermediate
|
||||
window.track('intermediate', window.tool.commit(() => {
|
||||
return 2
|
||||
}))
|
||||
}
|
||||
window.targetFrame.src = next
|
||||
window.track('new', window.tool.commit(() => {
|
||||
return new URL(location.href).searchParams.get('epoch')
|
||||
}))
|
||||
}, { next, intermediate: scenario === 'rapid-navigation' ? urlFor(2, false) : null })
|
||||
}
|
||||
await expect.poll(() => page.frames().some(frame => frame.url() === next)).toBe(true)
|
||||
child = page.frames().find(frame => frame.url() === next)
|
||||
await expect.poll(() => child.evaluate(() => Boolean(window.Tool))).toBe(true)
|
||||
await expect.poll(() => page.evaluate(() => window.states.old.status)).toBe('rejected')
|
||||
expect(await page.evaluate(() => window.states.old.error)).toBe('The iframe document has changed')
|
||||
expect(await page.evaluate(() => window.tool.injected)).toBe(false)
|
||||
if (scenario === 'rapid-navigation')
|
||||
expect(await page.evaluate(() => window.states.intermediate.status)).toBe('pending')
|
||||
await child.evaluate(() => window.Tool.inject())
|
||||
await expect.poll(() => page.evaluate(() => window.tool.injected)).toBe(true)
|
||||
const current = await page.evaluate(() => window.timeline.filter(item => item.packet?.type === 'inject').at(-1).packet.__artplayerIframe.document)
|
||||
expect(current).not.toBe(first)
|
||||
if (scenario === 'stale-packets') {
|
||||
await child.evaluate(({ document, id }) => parent.postMessage({ type: 'old-notification', data: 'stale', id, __artplayerIframe: { version: 1, document, phase: 'message' } }, '*'), { document: first, id: heldId })
|
||||
await expect.poll(() => page.evaluate(() => window.timeline.some(item => item.packet?.type === 'old-notification'))).toBe(true)
|
||||
expect(await page.evaluate(() => window.callbacks.some(packet => packet.type === 'old-notification'))).toBe(false)
|
||||
await page.evaluate(document => window.targetFrame.contentWindow.postMessage({ type: 'commit', data: 'window.executions++; return 999', id: 999, __artplayerIframe: { version: 1, document, phase: 'message' } }, '*'), first)
|
||||
await expect.poll(() => child.evaluate(() => window.received.some(packet => packet.id === 999))).toBe(true)
|
||||
expect(await child.evaluate(() => window.executions)).toBe(0)
|
||||
}
|
||||
if (scenario === 'history-back') {
|
||||
const beforeBack = await page.evaluate(() => window.timeline.filter(item => item.packet?.type === 'fixture-pageshow').length)
|
||||
await child.evaluate(() => history.back())
|
||||
await expect.poll(() => page.evaluate(count => window.timeline.filter(item => item.packet?.type === 'fixture-pageshow').length > count, beforeBack)).toBe(true)
|
||||
child = page.frames().find(frame => frame.url() === url)
|
||||
await expect.poll(() => page.evaluate(() => window.tool.injected)).toBe(true)
|
||||
const restored = await child.evaluate(() => ({ witness: window.documentWitness, epoch: new URL(location.href).searchParams.get('epoch') }))
|
||||
const pageshow = await page.evaluate(() => window.timeline.filter(item => item.packet?.type === 'fixture-pageshow').at(-1).packet.data)
|
||||
expect(restored.epoch).toBe('1')
|
||||
if (pageshow.persisted)
|
||||
expect(restored.witness).toBe(witness)
|
||||
const value = await page.evaluate(() => window.tool.commit(() => {
|
||||
return new URL(location.href).searchParams.get('epoch')
|
||||
}))
|
||||
expect(value).toBe('1')
|
||||
result = { pageshow, restored, actualBfcache: pageshow.persisted }
|
||||
}
|
||||
else {
|
||||
const value = await page.evaluate(() => window.tool.commit(() => {
|
||||
return new URL(location.href).searchParams.get('epoch')
|
||||
}))
|
||||
expect(value).toBe(scenario === 'rapid-navigation' ? '3' : '2')
|
||||
if (scenario === 'rapid-navigation') {
|
||||
await expect.poll(() => page.evaluate(() => window.states.intermediate.status)).toBe('resolved')
|
||||
expect(await page.evaluate(() => window.states.intermediate.value)).toBe(2)
|
||||
}
|
||||
if (scenario !== 'child-navigation') {
|
||||
await expect.poll(() => page.evaluate(() => window.states.new.status)).toBe('resolved')
|
||||
expect(await page.evaluate(() => window.states.new.value)).toBe(value)
|
||||
}
|
||||
result = await page.evaluate(() => ({ states: window.states, pending: Object.keys(window.tool.promises).length }))
|
||||
expect(result.pending).toBe(0)
|
||||
}
|
||||
}
|
||||
const timeline = await page.evaluate(() => window.timeline)
|
||||
expect(await page.evaluate(() => window.callbacks.some(packet => packet.type === 'artplayer-tool-iframe:session'))).toBe(false)
|
||||
await testInfo.attach('iframe-navigation', { contentType: 'application/json', body: JSON.stringify({ implementation: implementation.name, sha256: hash(implementation.code), relation, scenario, parentOrigin, childOrigin, result, timeline, scope: 'Actual native frame navigation and postMessage; history-back records actual pageshow.persisted rather than assuming bfcache. Delayed-load barrier forces injection before load. Stale-packet tests replay old metadata from the controlled current frame to isolate document filtering. No media/player integration.' }) })
|
||||
}
|
||||
finally {
|
||||
releaseBarrier?.()
|
||||
await page.evaluate(() => {
|
||||
window.tool.destroy()
|
||||
document.querySelectorAll('iframe').forEach(frame => frame.remove())
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const historical of (await iframeHistorical()).filter(item => ['published-artplayer-plugin-iframe.js', 'workspace.js'].includes(item.name))) {
|
||||
for (const relation of ['same-origin', 'cross-origin', 'opaque-origin']) {
|
||||
test(`Iframe legacy child navigation ${relation}/${historical.name}/${implementation.name}`, async ({ page }, testInfo) => {
|
||||
await page.goto('/test/player.html?core=published')
|
||||
const parentOrigin = new URL(page.url()).origin
|
||||
const childOrigin = relation === 'cross-origin' ? parentOrigin.replace('127.0.0.1', 'localhost') : parentOrigin
|
||||
const first = `${childOrigin}/test/iframe-boundary-child.html?global=${historical.global}&auto=1&epoch=1`
|
||||
const second = first.replace('auto=1&epoch=1', 'auto=0&epoch=2')
|
||||
await page.route('**/iframe-boundary-lib.js', route => route.fulfill({ contentType: 'text/javascript', body: historical.code }))
|
||||
await page.addScriptTag({ content: `(() => { const module = { exports: {} }; const exports = module.exports; ${implementation.code}; window.IframeFactory = module.exports.default || module.exports; })();` })
|
||||
await page.evaluate(({ first, opaque }) => {
|
||||
const frame = document.createElement('iframe')
|
||||
if (opaque)
|
||||
frame.sandbox = 'allow-scripts'
|
||||
document.body.append(frame)
|
||||
window.targetFrame = frame
|
||||
window.tool = new window.IframeFactory({ iframe: frame, url: first })
|
||||
window.states = {}
|
||||
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 })
|
||||
}
|
||||
}, { first, opaque: relation === 'opaque-origin' })
|
||||
try {
|
||||
await expect.poll(() => page.evaluate(() => window.tool.injected)).toBe(true)
|
||||
await page.evaluate((second) => {
|
||||
window.track('old', window.tool.postMessage({ type: 'hold' }))
|
||||
window.targetFrame.src = second
|
||||
window.track('new', window.tool.commit(() => {
|
||||
return new URL(location.href).searchParams.get('epoch')
|
||||
}))
|
||||
}, second)
|
||||
await expect.poll(() => page.frames().some(frame => frame.url() === second)).toBe(true)
|
||||
const child = page.frames().find(frame => frame.url() === second)
|
||||
await expect.poll(() => child.evaluate(() => Boolean(window.Tool))).toBe(true)
|
||||
await expect.poll(() => page.evaluate(() => window.states.old.status)).toBe('rejected')
|
||||
expect(await page.evaluate(() => window.tool.injected)).toBe(false)
|
||||
await child.evaluate(() => window.Tool.inject())
|
||||
await expect.poll(() => page.evaluate(() => window.states.new.status)).toBe('resolved')
|
||||
const result = await page.evaluate(() => ({ states: window.states, pending: Object.keys(window.tool.promises).length }))
|
||||
expect(result.states.new.value).toBe('2')
|
||||
expect(result.states.old.error).toBe('The iframe document has changed')
|
||||
expect(result.pending).toBe(0)
|
||||
await testInfo.attach('iframe-navigation', { contentType: 'application/json', body: JSON.stringify({ implementation: implementation.name, sha256: hash(implementation.code), relation, scenario: 'legacy-child-source-change', child: { name: historical.name, sha256: hash(historical.code) }, parentOrigin, childOrigin, result, scope: 'Actual source attribute navigation with an immutable historical child. No document metadata or internal child navigation protection is claimed for an unchanged legacy peer.' }) })
|
||||
}
|
||||
finally {
|
||||
await page.evaluate(() => {
|
||||
window.tool.destroy()
|
||||
window.targetFrame.remove()
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import assert from 'node:assert/strict'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Controlled document markers and owned MutationObserver callbacks.
|
||||
import test from 'node:test'
|
||||
import { iframeCandidate, iframeEnvironment } from './helpers/iframe.js'
|
||||
|
||||
const implementation = await iframeCandidate()
|
||||
const flush = () => new Promise(resolve => setImmediate(resolve))
|
||||
const control = 'artplayer-tool-iframe:session'
|
||||
function marked(type, document, phase, data, id = 0) {
|
||||
return { type, data, id, __artplayerIframe: { version: 1, document, phase } }
|
||||
}
|
||||
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
|
||||
}
|
||||
function setup() {
|
||||
const env = iframeEnvironment(implementation)
|
||||
const observers = []
|
||||
env.box.MutationObserver = class {
|
||||
records = []
|
||||
disconnected = false
|
||||
constructor(callback) {
|
||||
this.callback = callback
|
||||
observers.push(this)
|
||||
}
|
||||
|
||||
observe(target, options) {
|
||||
this.target = target
|
||||
this.options = options
|
||||
}
|
||||
|
||||
takeRecords() { return this.records.splice(0) }
|
||||
disconnect() {
|
||||
this.disconnected = true
|
||||
this.records = []
|
||||
}
|
||||
}
|
||||
const frame = new env.Frame()
|
||||
frame.hasAttribute = () => false
|
||||
frame.getAttribute = name => name === 'src' ? frame.src : null
|
||||
const instance = new env.Factory({ iframe: frame, url: '/first' })
|
||||
const inject = (document = 'first') => env.dispatch(marked('inject', document, 'inject'), frame.contentWindow)
|
||||
const navigate = (value) => {
|
||||
const oldValue = frame.src
|
||||
frame.src = value
|
||||
assert(observers[0], 'a navigation observer must own native attribute changes')
|
||||
observers[0].records.push({ attributeName: 'src', oldValue })
|
||||
}
|
||||
const tick = () => {
|
||||
for (const [id, timer] of [...env.timers]) {
|
||||
env.timers.delete(id)
|
||||
timer.callback()
|
||||
}
|
||||
}
|
||||
return { ...env, instance, frame, observers, inject, navigate, tick }
|
||||
}
|
||||
|
||||
test('Iframe document handshake keeps public callback data while acknowledging the peer privately', () => {
|
||||
const env = setup()
|
||||
const seen = []
|
||||
env.instance.message(packet => seen.push({ ...packet }))
|
||||
env.inject()
|
||||
assert.equal(env.instance.injected, true)
|
||||
assert.deepEqual(seen, [{ type: 'inject', data: undefined }])
|
||||
assert.equal(env.sent[0].packet.type, control)
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(env.sent[0].packet.__artplayerIframe)), { version: 1, document: 'first', phase: 'ack' })
|
||||
assert.deepEqual(Object.keys(env.instance), ['url', '$iframe', 'promises', 'injected', 'destroyed', 'messageCallback', 'onMessage'])
|
||||
env.instance.destroy()
|
||||
})
|
||||
|
||||
test('Iframe requests and replies remain associated with their active document', async () => {
|
||||
const env = setup()
|
||||
env.inject()
|
||||
const state = observe(env.instance.postMessage({ type: 'query' }))
|
||||
const request = env.sent.at(-1).packet
|
||||
assert.equal(request.__artplayerIframe.document, 'first')
|
||||
env.dispatch(marked('response', 'wrong', 'message', 'wrong reply', request.id), env.frame.contentWindow)
|
||||
await flush()
|
||||
assert.equal(state.status, 'pending')
|
||||
env.dispatch(marked('custom', 'first', 'message', 7, request.id), env.frame.contentWindow)
|
||||
await flush()
|
||||
assert.deepEqual(state, { status: 'resolved', value: 7 })
|
||||
env.instance.destroy()
|
||||
})
|
||||
|
||||
test('Iframe a different document handshake cancels old requests and drops old notifications', async () => {
|
||||
const env = setup()
|
||||
env.inject()
|
||||
const state = observe(env.instance.postMessage({ type: 'query' }))
|
||||
const id = env.sent.at(-1).packet.id
|
||||
const messages = []
|
||||
env.instance.message(packet => messages.push(packet.type))
|
||||
env.inject('second')
|
||||
env.dispatch(marked('response', 'first', 'message', 'late', id), env.frame.contentWindow)
|
||||
env.dispatch(marked('custom-old', 'first', 'message', 'late'), env.frame.contentWindow)
|
||||
await flush()
|
||||
assert.deepEqual(state, { status: 'rejected', error: 'The iframe document has changed' })
|
||||
assert.deepEqual(messages, ['inject'])
|
||||
assert.equal(Object.keys(env.instance.promises).length, 0)
|
||||
env.instance.destroy()
|
||||
})
|
||||
|
||||
test('Iframe repeated injection from the same document preserves its pending requests', async () => {
|
||||
const env = setup()
|
||||
env.inject()
|
||||
const state = observe(env.instance.postMessage({ type: 'query' }))
|
||||
const id = env.sent.at(-1).packet.id
|
||||
env.inject()
|
||||
env.dispatch(marked('response', 'first', 'message', 8, id), env.frame.contentWindow)
|
||||
await flush()
|
||||
assert.deepEqual(state, { status: 'resolved', value: 8 })
|
||||
env.instance.destroy()
|
||||
})
|
||||
|
||||
test('Iframe synchronous post after src change cancels old work and queues for the new injection', async () => {
|
||||
const env = setup()
|
||||
env.inject()
|
||||
const first = observe(env.instance.postMessage({ type: 'query' }))
|
||||
env.navigate('/second')
|
||||
const second = observe(env.instance.postMessage({ type: 'query' }))
|
||||
env.dispatch(marked(control, 'first', 'leave'), env.frame.contentWindow)
|
||||
await flush()
|
||||
assert.deepEqual(first, { status: 'rejected', error: 'The iframe document has changed' })
|
||||
assert.equal(second.status, 'pending')
|
||||
assert.equal(env.instance.injected, false)
|
||||
assert.equal(env.observers[0].records.length, 0)
|
||||
assert.equal(env.sent.filter(item => item.packet.type === 'query').length, 1)
|
||||
env.inject('second')
|
||||
env.tick()
|
||||
const request = env.sent.at(-1).packet
|
||||
assert.equal(request.__artplayerIframe.document, 'second')
|
||||
env.dispatch(marked('response', 'second', 'message', 9, request.id), env.frame.contentWindow)
|
||||
await flush()
|
||||
assert.deepEqual(second, { status: 'resolved', value: 9 })
|
||||
assert.equal(env.instance.url, '/first')
|
||||
env.instance.destroy()
|
||||
})
|
||||
|
||||
test('Iframe old pagehide and repeated old inject cannot cancel or activate a newly queued navigation', async () => {
|
||||
const env = setup()
|
||||
env.inject()
|
||||
env.navigate('/second')
|
||||
const state = observe(env.instance.postMessage({ type: 'query' }))
|
||||
env.inject('first')
|
||||
env.dispatch(marked(control, 'first', 'leave'), env.frame.contentWindow)
|
||||
env.dispatch(marked(control, 'first', 'leave'), env.frame.contentWindow)
|
||||
await flush()
|
||||
assert.equal(state.status, 'pending')
|
||||
assert.equal(env.instance.injected, false)
|
||||
env.inject('second')
|
||||
env.tick()
|
||||
const request = env.sent.at(-1).packet
|
||||
env.dispatch(marked('response', 'second', 'message', 10, request.id), env.frame.contentWindow)
|
||||
await flush()
|
||||
assert.deepEqual(state, { status: 'resolved', value: 10 })
|
||||
env.instance.destroy()
|
||||
})
|
||||
|
||||
test('Iframe native attribute observation suspends sends and confirmed departure cancels old work', async () => {
|
||||
const env = setup()
|
||||
env.inject()
|
||||
const state = observe(env.instance.postMessage({ type: 'query' }))
|
||||
env.navigate('/second')
|
||||
const observer = env.observers[0]
|
||||
observer.callback(observer.takeRecords())
|
||||
await flush()
|
||||
assert.equal(state.status, 'pending')
|
||||
assert.equal(env.instance.injected, false)
|
||||
env.dispatch(marked(control, 'first', 'leave'), env.frame.contentWindow)
|
||||
await flush()
|
||||
assert.deepEqual(state, { status: 'rejected', error: 'The iframe document has changed' })
|
||||
assert.equal(env.instance.injected, false)
|
||||
env.instance.destroy()
|
||||
assert.equal(observer.disconnected, true)
|
||||
observer.callback([{ attributeName: 'src', oldValue: '/second' }])
|
||||
assert.equal(env.timers.size, 0)
|
||||
})
|
||||
|
||||
test('Iframe consecutive source mutations preserve requests queued after navigation started', async () => {
|
||||
for (const departed of [false, true]) {
|
||||
const env = setup()
|
||||
env.inject()
|
||||
const old = observe(env.instance.postMessage({ type: 'query' }))
|
||||
env.navigate('/second')
|
||||
const queued = observe(env.instance.postMessage({ type: 'query' }))
|
||||
if (departed)
|
||||
env.dispatch(marked(control, 'first', 'leave'), env.frame.contentWindow)
|
||||
env.navigate('/third')
|
||||
env.observers[0].callback(env.observers[0].takeRecords())
|
||||
env.dispatch(marked(control, 'first', 'leave'), env.frame.contentWindow)
|
||||
await flush()
|
||||
assert.equal(old.error, 'The iframe document has changed')
|
||||
assert.equal(queued.status, 'pending')
|
||||
env.inject('third')
|
||||
env.tick()
|
||||
const request = env.sent.at(-1).packet
|
||||
env.dispatch(marked('response', 'third', 'message', 13, request.id), env.frame.contentWindow)
|
||||
await flush()
|
||||
assert.deepEqual(queued, { status: 'resolved', value: 13 })
|
||||
env.instance.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test('Iframe confirmed fragment navigation preserves requests; a same-source document departure cancels them', async () => {
|
||||
const env = setup()
|
||||
env.inject()
|
||||
const state = observe(env.instance.postMessage({ type: 'query' }))
|
||||
env.navigate('/first#chapter')
|
||||
const observer = env.observers[0]
|
||||
observer.callback(observer.takeRecords())
|
||||
env.dispatch(marked(control, 'first', 'fragment', '/first#chapter'), env.frame.contentWindow)
|
||||
await flush()
|
||||
assert.equal(state.status, 'pending')
|
||||
assert.equal(env.instance.injected, true)
|
||||
env.navigate('/first#chapter')
|
||||
observer.callback(observer.takeRecords())
|
||||
env.dispatch(marked(control, 'first', 'leave'), env.frame.contentWindow)
|
||||
await flush()
|
||||
assert.deepEqual(state, { status: 'rejected', error: 'The iframe document has changed' })
|
||||
env.instance.destroy()
|
||||
})
|
||||
|
||||
test('Iframe pagehide cancels current work; a restored document can resume after another document', async () => {
|
||||
const env = setup()
|
||||
env.inject()
|
||||
const first = observe(env.instance.postMessage({ type: 'query' }))
|
||||
env.dispatch(marked(control, 'first', 'leave'), env.frame.contentWindow)
|
||||
await flush()
|
||||
assert.equal(first.error, 'The iframe document has changed')
|
||||
env.inject('second')
|
||||
const second = observe(env.instance.postMessage({ type: 'query' }))
|
||||
env.dispatch(marked(control, 'first', 'resume'), env.frame.contentWindow)
|
||||
await flush()
|
||||
assert.equal(second.error, 'The iframe document has changed')
|
||||
const restored = observe(env.instance.postMessage({ type: 'query' }))
|
||||
assert.equal(env.sent.at(-1).packet.__artplayerIframe.document, 'first')
|
||||
env.instance.destroy()
|
||||
await flush()
|
||||
assert.equal(restored.error, 'The instance has been destroyed')
|
||||
})
|
||||
|
||||
test('Iframe legacy peer remains unmarked and source replacement still waits for its ordinary injection', async () => {
|
||||
const env = setup()
|
||||
env.dispatch({ type: 'inject' }, env.frame.contentWindow)
|
||||
const first = observe(env.instance.postMessage({ type: 'query' }))
|
||||
assert.deepEqual(Object.keys(env.sent.at(-1).packet), ['type', 'data', 'id'])
|
||||
env.navigate('/second')
|
||||
const second = observe(env.instance.postMessage({ type: 'query' }))
|
||||
env.dispatch({ type: 'inject' }, env.frame.contentWindow)
|
||||
env.tick()
|
||||
const request = env.sent.at(-1).packet
|
||||
assert.deepEqual(Object.keys(request), ['type', 'data', 'id'])
|
||||
env.dispatch({ type: 'response', data: 11, id: request.id }, env.frame.contentWindow)
|
||||
await flush()
|
||||
assert.equal(first.error, 'The iframe document has changed')
|
||||
assert.deepEqual(second, { status: 'resolved', value: 11 })
|
||||
env.instance.destroy()
|
||||
})
|
||||
|
||||
test('Iframe child advertises a document without changing ordinary messages to an unmodified parent', () => {
|
||||
const env = iframeEnvironment(implementation, true)
|
||||
env.Factory.inject()
|
||||
const injection = env.sent[0].packet
|
||||
assert.equal(injection.type, 'inject')
|
||||
assert.equal(injection.id, 0)
|
||||
assert.equal(injection.data, undefined)
|
||||
assert.equal(injection.__artplayerIframe.version, 1)
|
||||
assert.equal(typeof injection.__artplayerIframe.document, 'string')
|
||||
env.Factory.postMessage({ type: 'custom', data: 1 })
|
||||
assert.deepEqual(Object.keys(env.sent.at(-1).packet), ['type', 'data', 'id'])
|
||||
for (const callback of env.handlers.get('pagehide') || []) callback({})
|
||||
assert.equal(env.sent.length, 2)
|
||||
})
|
||||
|
||||
test('Iframe child tags acknowledged responses, ignores stale-document commands and hides lifecycle from legacy parents', async () => {
|
||||
const env = iframeEnvironment(implementation, true)
|
||||
env.Factory.inject()
|
||||
const document = env.sent[0].packet.__artplayerIframe.document
|
||||
await env.Factory.onMessage({ data: marked(control, document, 'ack'), source: env.box.parent })
|
||||
await env.Factory.onMessage({ data: marked('commit', 'old-document', 'message', 'return 7', 7), source: env.box.parent })
|
||||
assert.equal(env.sent.length, 1)
|
||||
await env.Factory.onMessage({ data: marked('commit', document, 'message', 'return 8', 8), source: env.box.parent })
|
||||
assert.equal(env.sent.at(-1).packet.data, 8)
|
||||
assert.equal(env.sent.at(-1).packet.__artplayerIframe.document, document)
|
||||
env.Factory.inject()
|
||||
assert.equal(env.handlers.get('pagehide').size, 1)
|
||||
assert.equal(env.handlers.get('pageshow').size, 1)
|
||||
for (const callback of env.handlers.get('pagehide')) callback({})
|
||||
assert.equal(env.sent.at(-1).packet.__artplayerIframe.phase, 'leave')
|
||||
for (const callback of env.handlers.get('pageshow')) callback({ persisted: true })
|
||||
assert.equal(env.sent.at(-1).packet.__artplayerIframe.phase, 'resume')
|
||||
})
|
||||
|
||||
test('Iframe failed child listener acquisition releases partial registrations and can be retried', () => {
|
||||
const env = iframeEnvironment(implementation, true)
|
||||
const add = env.box.addEventListener
|
||||
const failure = new Error('pageshow listener failure')
|
||||
env.box.addEventListener = (name, callback) => {
|
||||
add(name, callback)
|
||||
if (name === 'pageshow')
|
||||
throw failure
|
||||
}
|
||||
assert.throws(() => env.Factory.inject(), error => error === failure)
|
||||
for (const name of ['message', 'pagehide', 'pageshow']) assert.equal(env.handlers.get(name).size, 0)
|
||||
env.box.addEventListener = add
|
||||
env.Factory.inject()
|
||||
for (const name of ['message', 'pagehide', 'pageshow']) assert.equal(env.handlers.get(name).size, 1)
|
||||
})
|
||||
|
||||
test('Iframe internal cancellation retains ownership when a consumer replaces public reject', async () => {
|
||||
const env = setup()
|
||||
env.inject()
|
||||
const state = observe(env.instance.postMessage({ type: 'query' }))
|
||||
const id = env.sent.at(-1).packet.id
|
||||
let calls = 0
|
||||
env.instance.promises[id].reject = () => calls++
|
||||
env.dispatch(marked('error', 'first', 'message', 'remote error', id), env.frame.contentWindow)
|
||||
await flush()
|
||||
assert.equal(calls, 1)
|
||||
assert.equal(state.status, 'pending')
|
||||
env.instance.destroy()
|
||||
await flush()
|
||||
assert.deepEqual(state, { status: 'rejected', error: 'The instance has been destroyed' })
|
||||
assert.equal(calls, 1)
|
||||
assert.equal(Object.keys(env.instance.promises).length, 0)
|
||||
})
|
||||
|
||||
test('Iframe a changed src getter resolution without an attribute mutation does not invent a navigation', async () => {
|
||||
const env = setup()
|
||||
env.inject()
|
||||
env.frame.getAttribute = name => name === 'src' ? '/first' : null
|
||||
Object.defineProperty(env.frame, 'src', { value: 'https://new-base.example/first' })
|
||||
const state = observe(env.instance.postMessage({ type: 'query' }))
|
||||
assert.equal(env.instance.injected, true)
|
||||
assert.equal(env.sent.at(-1).packet.type, 'query')
|
||||
env.dispatch(marked('response', 'first', 'message', 13, env.sent.at(-1).packet.id), env.frame.contentWindow)
|
||||
await flush()
|
||||
assert.deepEqual(state, { status: 'resolved', value: 13 })
|
||||
env.instance.destroy()
|
||||
})
|
||||
Reference in new issue
Block a user