refactor(iframe): [PKG-IFRAME-03] scope requests to active documents

This commit is contained in:
Harvey Zhao committed 2026-09-13 01:07:37 +08:00
1 parent b8cdafedd1
commit 1342de94ef
27 files changed
+10860 -69

No files matched your search

+1 -1
View File
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.ArtplayerToolIframe=t(),define(function(){return e.ArtplayerToolIframe})):(e="undefined"!==typeof globalThis?globalThis:e||self).ArtplayerToolIframe=t()}(this,function(){"use strict";const e=new WeakMap;let t=0;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
View File
@@ -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
View File
@@ -51,7 +51,7 @@
"ci:build": "yarn build:types && yarn build all && yarn build:i18n && yarn build:ts && yarn build:docs && yarn test:imports",
"test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js",
"typecheck": "node scripts/typecheck.mjs",
"test:unit": "node --test test/iframe-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": {
+45 -3
View File
@@ -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
View File
@@ -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 {
+9 -5
View File
@@ -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
+34 -5
View File
@@ -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.
+4
View File
@@ -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);本轮不将来源绑定视为导航或全包发布验收。
+64
View File
@@ -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
View File
@@ -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)
+14
View File
@@ -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即使
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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": []
},
+1 -1
View File
@@ -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
}))
+12 -1
View File
@@ -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>
+291
View File
@@ -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()
})
}
})
}
}
+341
View File
@@ -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()
})