fix(iframe): [PKG-IFRAME-03] own pending requests and cancellation

This commit is contained in:
Harvey Zhao committed 2026-09-12 23:41:48 +08:00
1 parent b3f22652c2
commit b8d03e7366
21 files changed
+3826 -107

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?(t.ArtplayerToolIframe=t(),define(function(){return t.ArtplayerToolIframe})):(e="undefined"!==typeof globalThis?globalThis:e||self).ArtplayerToolIframe=t()}(this,function(){"use strict";class e{static get iframe(){return window.top!==window}static postMessage({type:t,data:s,id:i=0}){if(!e.iframe)throw new Error('The "ArtplayerToolIframe.postMessage" method can only be used in iframe');window.parent.postMessage({type:t,data:s,id:i},"*")}static async onMessage(t){if(!e.iframe)throw new Error('The "ArtplayerToolIframe.onMessage" method can only be used in iframe');const{type:s,data:i,id:o}=t.data;if("commit"===s)try{if(i.match(/\bresolve\((.*?)\)/)){const t=`return new Promise(function(resolve){\n${i}\n})`,s=await new Function(t)();e.postMessage({type:"response",data:s,id:o})}else{const t=new Function(i)();e.postMessage({type:"response",data:t,id:o})}}catch(n){throw e.postMessage({type:"error",data:n.message,id:o}),n}}static inject(){if(!e.iframe)throw new Error('The "ArtplayerToolIframe.inject" method can only be used in iframe');e.postMessage({type:"inject"}),window.addEventListener("message",e.onMessage)}constructor({iframe:e,url:t}){if(e instanceof HTMLIFrameElement===!1)throw new TypeError('"option.iframe" needs to be a HTMLIFrameElement');if("string"!==typeof t)throw new TypeError('"option.url" needs to be a string');this.url=t,this.$iframe=e,this.promises={},this.injected=!1,this.destroyed=!1,this.messageCallback=()=>null,this.onMessage=this.onMessage.bind(this),window.addEventListener("message",this.onMessage),this.$iframe.src=this.url}onMessage(e){const{type:t,data:s,id:i}=e.data;if("inject"===t)this.injected=!0;this.promises[i]&&("error"===t?this.promises[i].reject(new Error(s)):this.promises[i].resove(s),delete this.promises[i]),this.messageCallback&&this.messageCallback({type:t,data:s})}postMessage({type:e,data:t}){return new Promise((s,i)=>{(function o(){if(this.destroyed)i(new Error("The instance has been destroyed"));else if(this.injected){const o=Date.now();this.promises[o]={resove:s,reject:i},this.$iframe.contentWindow.postMessage({type:e,data:t,id:o},"*")}else setTimeout(o.bind(this),200)}).call(this)})}commit(e){if("function"!==typeof e)throw new TypeError('"commit.callback" needs to be a function');const t=e.toString(),s=t.substring(t.indexOf("{")+1,t.lastIndexOf("}"));return this.postMessage({type:"commit",data:s})}message(e){if("function"!==typeof e)throw new TypeError('"message.callback" needs to be a function');this.messageCallback=e}destroy(){this.destroyed=!0,window.removeEventListener("message",this.onMessage)}}return e});
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.ArtplayerToolIframe=t(),define(function(){return e.ArtplayerToolIframe})):(e="undefined"!==typeof globalThis?globalThis:e||self).ArtplayerToolIframe=t()}(this,function(){"use strict";const e=new WeakMap;let t=0;class s{static get iframe(){return window.top!==window}static postMessage({type:e,data:t,id:o=0}){if(!s.iframe)throw new Error('The "ArtplayerToolIframe.postMessage" method can only be used in iframe');window.parent.postMessage({type:e,data:t,id:o},"*")}static async onMessage(e){if(!s.iframe)throw new Error('The "ArtplayerToolIframe.onMessage" method can only be used in iframe');const{type:t,data:o,id:i}=e.data;if("commit"===t)try{if(o.match(/\bresolve\((.*?)\)/)){const e=`return new Promise(function(resolve){\n${o}\n})`,t=await new Function(e)();s.postMessage({type:"response",data:t,id:i})}else{const e=new Function(o)();s.postMessage({type:"response",data:e,id:i})}}catch(r){throw s.postMessage({type:"error",data:r.message,id:i}),r}}static inject(){if(!s.iframe)throw new Error('The "ArtplayerToolIframe.inject" method can only be used in iframe');s.postMessage({type:"inject"}),window.addEventListener("message",s.onMessage)}constructor({iframe:e,url:t}){if(e instanceof HTMLIFrameElement===!1)throw new TypeError('"option.iframe" needs to be a HTMLIFrameElement');if("string"!==typeof t)throw new TypeError('"option.url" needs to be a string');this.url=t,this.$iframe=e,this.promises={},this.injected=!1,this.destroyed=!1,this.messageCallback=()=>null,this.onMessage=this.onMessage.bind(this),window.addEventListener("message",this.onMessage),this.$iframe.src=this.url}onMessage(e){if(this.destroyed)return;const{type:t,data:s,id:o}=e.data;if("inject"===t)this.injected=!0;void 0!==o&&Object.prototype.hasOwnProperty.call(this.promises,o)&&this.promises[o]&&("error"===t?this.promises[o].reject(new Error(s)):this.promises[o].resove(s),delete this.promises[o]),this.messageCallback&&this.messageCallback({type:t,data:s})}postMessage(s){return function(s,{type:o,data:i}){return new Promise((r,n)=>{if(s.destroyed)return void n(new Error("The instance has been destroyed"));const a=e.get(s)||new Set;e.set(s,a);const d=t=>!!a.delete(t)&&(void 0!==t.timer&&clearTimeout(t.timer),void 0!==t.id&&delete s.promises[t.id],a.size||e.delete(s),!0),c={callbacks:{resove(e){d(c)&&r(e)},reject(e){d(c)&&n(e)}}};a.add(c);const l=()=>{if(c.timer=void 0,a.has(c))if(s.destroyed)c.callbacks.reject(new Error("The instance has been destroyed"));else try{if(s.injected){const e=Math.max(Date.now(),t+1);t=e,c.id=e,s.promises[e]=c.callbacks,s.$iframe.contentWindow.postMessage({type:o,data:i,id:e},"*")}else c.timer=setTimeout(l,200)}catch(e){c.callbacks.reject(e)}};l()})}(this,s)}commit(e){if("function"!==typeof e)throw new TypeError('"commit.callback" needs to be a function');const t=e.toString(),s=t.substring(t.indexOf("{")+1,t.lastIndexOf("}"));return this.postMessage({type:"commit",data:s})}message(e){if("function"!==typeof e)throw new TypeError('"message.callback" needs to be a function');this.messageCallback=e}destroy(){this.destroyed=!0,window.removeEventListener("message",this.onMessage),function(t){const s=e.get(t);if(s)for(const e of[...s])e.callbacks.reject(new Error("The instance has been destroyed"))}(this)}}return s});
@@ -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?(t.ArtplayerToolIframe=t(),define(function(){return t.ArtplayerToolIframe})):(e="undefined"!==typeof globalThis?globalThis:e||self).ArtplayerToolIframe=t()}(this,function(){"use strict";class e{static get iframe(){return window.top!==window}static postMessage({type:t,data:s,id:o=0}){if(!e.iframe)throw new Error('The "ArtplayerToolIframe.postMessage" method can only be used in iframe');window.parent.postMessage({type:t,data:s,id:o},"*")}static onMessage(t){return s=this,o=null,i=function*(){if(!e.iframe)throw new Error('The "ArtplayerToolIframe.onMessage" method can only be used in iframe');const{type:s,data:o,id:i}=t.data;if("commit"===s)try{if(o.match(/\bresolve\((.*?)\)/)){const t=`return new Promise(function(resolve){\n${o}\n})`,s=yield new Function(t)();e.postMessage({type:"response",data:s,id:i})}else{const t=new Function(o)();e.postMessage({type:"response",data:t,id:i})}}catch(n){throw e.postMessage({type:"error",data:n.message,id:i}),n}},new Promise((e,t)=>{var n=e=>{try{a(i.next(e))}catch(s){t(s)}},r=e=>{try{a(i.throw(e))}catch(s){t(s)}},a=t=>t.done?e(t.value):Promise.resolve(t.value).then(n,r);a((i=i.apply(s,o)).next())});var s,o,i}static inject(){if(!e.iframe)throw new Error('The "ArtplayerToolIframe.inject" method can only be used in iframe');e.postMessage({type:"inject"}),window.addEventListener("message",e.onMessage)}constructor({iframe:e,url:t}){if(e instanceof HTMLIFrameElement===!1)throw new TypeError('"option.iframe" needs to be a HTMLIFrameElement');if("string"!==typeof t)throw new TypeError('"option.url" needs to be a string');this.url=t,this.$iframe=e,this.promises={},this.injected=!1,this.destroyed=!1,this.messageCallback=()=>null,this.onMessage=this.onMessage.bind(this),window.addEventListener("message",this.onMessage),this.$iframe.src=this.url}onMessage(e){const{type:t,data:s,id:o}=e.data;if("inject"===t)this.injected=!0;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({type:e,data:t}){return new Promise((s,o)=>{(function i(){if(this.destroyed)o(new Error("The instance has been destroyed"));else if(this.injected){const i=Date.now();this.promises[i]={resove:s,reject:o},this.$iframe.contentWindow.postMessage({type:e,data:t,id:i},"*")}else setTimeout(i.bind(this),200)}).call(this)})}commit(e){if("function"!==typeof e)throw new TypeError('"commit.callback" needs to be a function');const t=e.toString(),s=t.substring(t.indexOf("{")+1,t.lastIndexOf("}"));return this.postMessage({type:"commit",data:s})}message(e){if("function"!==typeof e)throw new TypeError('"message.callback" needs to be a function');this.messageCallback=e}destroy(){this.destroyed=!0,window.removeEventListener("message",this.onMessage)}}return e});
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.ArtplayerToolIframe=t(),define(function(){return e.ArtplayerToolIframe})):(e="undefined"!==typeof globalThis?globalThis:e||self).ArtplayerToolIframe=t()}(this,function(){"use strict";const e=new WeakMap;let t=0;class s{static get iframe(){return window.top!==window}static postMessage({type:e,data:t,id:o=0}){if(!s.iframe)throw new Error('The "ArtplayerToolIframe.postMessage" method can only be used in iframe');window.parent.postMessage({type:e,data:t,id:o},"*")}static onMessage(e){return t=this,o=null,r=function*(){if(!s.iframe)throw new Error('The "ArtplayerToolIframe.onMessage" method can only be used in iframe');const{type:t,data:o,id:r}=e.data;if("commit"===t)try{if(o.match(/\bresolve\((.*?)\)/)){const e=`return new Promise(function(resolve){\n${o}\n})`,t=yield new Function(e)();s.postMessage({type:"response",data:t,id:r})}else{const e=new Function(o)();s.postMessage({type:"response",data:e,id:r})}}catch(i){throw s.postMessage({type:"error",data:i.message,id:r}),i}},new Promise((e,s)=>{var i=e=>{try{a(r.next(e))}catch(t){s(t)}},n=e=>{try{a(r.throw(e))}catch(t){s(t)}},a=t=>t.done?e(t.value):Promise.resolve(t.value).then(i,n);a((r=r.apply(t,o)).next())});var t,o,r}static inject(){if(!s.iframe)throw new Error('The "ArtplayerToolIframe.inject" method can only be used in iframe');s.postMessage({type:"inject"}),window.addEventListener("message",s.onMessage)}constructor({iframe:e,url:t}){if(e instanceof HTMLIFrameElement===!1)throw new TypeError('"option.iframe" needs to be a HTMLIFrameElement');if("string"!==typeof t)throw new TypeError('"option.url" needs to be a string');this.url=t,this.$iframe=e,this.promises={},this.injected=!1,this.destroyed=!1,this.messageCallback=()=>null,this.onMessage=this.onMessage.bind(this),window.addEventListener("message",this.onMessage),this.$iframe.src=this.url}onMessage(e){if(this.destroyed)return;const{type:t,data:s,id:o}=e.data;if("inject"===t)this.injected=!0;void 0!==o&&Object.prototype.hasOwnProperty.call(this.promises,o)&&this.promises[o]&&("error"===t?this.promises[o].reject(new Error(s)):this.promises[o].resove(s),delete this.promises[o]),this.messageCallback&&this.messageCallback({type:t,data:s})}postMessage(s){return function(s,{type:o,data:r}){return new Promise((i,n)=>{if(s.destroyed)return void n(new Error("The instance has been destroyed"));const a=e.get(s)||new Set;e.set(s,a);const d=t=>!!a.delete(t)&&(void 0!==t.timer&&clearTimeout(t.timer),void 0!==t.id&&delete s.promises[t.id],a.size||e.delete(s),!0),c={callbacks:{resove(e){d(c)&&i(e)},reject(e){d(c)&&n(e)}}};a.add(c);const l=()=>{if(c.timer=void 0,a.has(c))if(s.destroyed)c.callbacks.reject(new Error("The instance has been destroyed"));else try{if(s.injected){const e=Math.max(Date.now(),t+1);t=e,c.id=e,s.promises[e]=c.callbacks,s.$iframe.contentWindow.postMessage({type:o,data:r,id:e},"*")}else c.timer=setTimeout(l,200)}catch(e){c.callbacks.reject(e)}};l()})}(this,s)}commit(e){if("function"!==typeof e)throw new TypeError('"commit.callback" needs to be a function');const t=e.toString(),s=t.substring(t.indexOf("{")+1,t.lastIndexOf("}"));return this.postMessage({type:"commit",data:s})}message(e){if("function"!==typeof e)throw new TypeError('"message.callback" needs to be a function');this.messageCallback=e}destroy(){this.destroyed=!0,window.removeEventListener("message",this.onMessage),function(t){const s=e.get(t);if(s)for(const e of[...s])e.callbacks.reject(new Error("The instance has been destroyed"))}(this)}}return s});
+72 -24
View File
@@ -4,6 +4,72 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
const requests = /* @__PURE__ */ new WeakMap();
let lastId = 0;
function postRequest(host, { type, data }) {
return new Promise((resolve, reject) => {
if (host.destroyed) {
reject(new Error("The instance has been destroyed"));
return;
}
const pending = requests.get(host) || /* @__PURE__ */ new Set();
requests.set(host, pending);
const finish = (request2) => {
if (!pending.delete(request2))
return false;
if (request2.timer !== void 0)
clearTimeout(request2.timer);
if (request2.id !== void 0)
delete host.promises[request2.id];
if (!pending.size)
requests.delete(host);
return true;
};
const request = {
callbacks: {
resove(value) {
if (finish(request))
resolve(value);
},
reject(error) {
if (finish(request))
reject(error);
}
}
};
pending.add(request);
const loop = () => {
request.timer = void 0;
if (!pending.has(request))
return;
if (host.destroyed) {
request.callbacks.reject(new Error("The instance has been destroyed"));
return;
}
try {
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 }, "*");
} else {
request.timer = setTimeout(loop, 200);
}
} catch (error) {
request.callbacks.reject(error);
}
};
loop();
});
}
function cancelRequests(host) {
const pending = requests.get(host);
if (!pending)
return;
for (const request of [...pending])
request.callbacks.reject(new Error("The instance has been destroyed"));
}
class ArtplayerToolIframe {
static get iframe() {
return window.top !== window;
@@ -71,13 +137,15 @@ ${data}
this.$iframe.src = this.url;
}
onMessage(event) {
if (this.destroyed)
return;
const { type, data, id } = event.data;
switch (type) {
case "inject":
this.injected = true;
break;
}
if (this.promises[id]) {
if (id !== void 0 && Object.prototype.hasOwnProperty.call(this.promises, id) && this.promises[id]) {
if (type === "error") {
this.promises[id].reject(new Error(data));
} else {
@@ -89,29 +157,8 @@ ${data}
this.messageCallback({ type, data });
}
}
postMessage({ type, data }) {
return new Promise((resove, reject) => {
(function loop() {
if (this.destroyed) {
reject(new Error("The instance has been destroyed"));
} else {
if (this.injected) {
const id = Date.now();
this.promises[id] = { resove, reject };
this.$iframe.contentWindow.postMessage(
{
type,
data,
id
},
"*"
);
} else {
setTimeout(loop.bind(this), 200);
}
}
}).call(this);
});
postMessage(message) {
return postRequest(this, message);
}
commit(callback) {
if (typeof callback !== "function") {
@@ -130,6 +177,7 @@ ${data}
destroy() {
this.destroyed = true;
window.removeEventListener("message", this.onMessage);
cancelRequests(this);
}
}
export {
+2 -2
View File
@@ -51,7 +51,7 @@
"ci:build": "yarn build:types && yarn build all && yarn build:i18n && yarn build:ts && yarn build:docs && yarn test:imports",
"test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js",
"typecheck": "node scripts/typecheck.mjs",
"test:unit": "node --test test/iframe.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-lifecycle.test.js test/iframe.test.js test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-coordination.test.js test/mediabunny-video.test.js test/mediabunny-audio.test.js test/mediabunny-hls.test.js test/mediabunny-entry.test.js test/mediabunny-capability.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js test/dpip.test.js test/dpip-lifecycle.test.js test/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js",
"test:coverage": "node --test test/coverage.test.js && node scripts/coverage.mjs",
"test": "yarn test:node && yarn test:baseline",
"test:browser": "playwright test",
@@ -80,7 +80,7 @@
"test:mediabunny-types-package": "node refactor/scripts/mb-package-types.mjs",
"test:mediabunny-soak": "playwright test --config playwright.soak.config.js",
"generate:media:hls": "node scripts/generate-hls-fixture.mjs",
"test:iframe": "node --test test/iframe.test.js refactor/scripts/iframe-contract.test.mjs"
"test:iframe": "node --test test/iframe.test.js test/iframe-lifecycle.test.js refactor/scripts/iframe-contract.test.mjs"
},
"browserslist": "last 1 Chrome version",
"devDependencies": {
@@ -0,0 +1,78 @@
# Iframe maintenance map
`src/index.ts` owns the existing class, constructor validation, bound parent
message listener, child injection/execution protocol and public callbacks.
`src/requests.ts` owns request IDs, pending promises, injection polling and
settlement/cancellation. Its host interface uses only the iframe, registry and
two lifecycle flags; it does not depend on ArtPlayer or import the entry class.
The entry initializes the same seven enumerable, writable fields in the same
order. Internal request ownership lives in a module WeakMap, keeping instance
reflection unchanged. `promises` remains the same ordinary object, exposing the
historical `resove` spelling and `reject`. Do not rename `resove` or replace this
public object with a Map. The wire envelope remains `{ type, data, id }`, with
numeric IDs and wildcard target origin. Non-error responses with a matching ID
are still accepted; `message` receives `{ type, data }` with the instance as `this`.
## Request lifecycle
Every call owns its promise immediately. Before injection it polls every 200ms,
as before; injection does not flush the queue synchronously. On send it allocates
a numeric ID from the greater of the current timestamp and the previous ID + 1.
The counter is shared by instances in this module so two instances pointing at
one child do not settle each other's requests. It also avoids reuse after clock
rollback. Independently loaded copies of the library do not share this counter.
The request is recorded before native postMessage. Success, remote error, native
send failure or an explicit public callback settle once and remove ownership,
timer and registry entry. Native failures retain their original Error object,
including failures reached from polling. `destroy()` first marks the instance
destroyed, removes its listener, then immediately rejects both sent and waiting
requests with `The instance has been destroyed`. Saved listener/timer callbacks
become inert. Repeated destroy is harmless. There is no implicit request timeout.
These are deliberate defect corrections: callers holding a promise during
destroy must handle rejection; previously sent requests remained pending forever.
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.
## Compatibility still under review
Child `commit` still extracts the function body and evaluates it with the old
`resolve(...)` convention. Simple commits post a response synchronously inside
the async handler; resolver commits await the result. Execution errors still
send an error packet and reject the handler. Expression arrow functions, closure
capture and CSP restrictions have not been replaced with a new RPC protocol.
This checkpoint does not fix navigation/reinjection, malformed message handling
or source/origin validation. The executable commit protocol is not a sandbox;
the current listener still accepts messages from unrelated windows. IFRAME-LIFE-01
and IFRAME-TRUST-01 remain open. Continue in PKG-IFRAME-03/05 before release.
Public declarations remain in `types/artplayer-tool-iframe.d.ts`; source typing
does not yet establish historical declaration/entry compatibility. PKG-IFRAME-04
must reconcile the old npm plugin name, namespace/default and extra helper
protocol separately. PKG-IFRAME-06 owns final distribution. No version bump or
publication is implied by this source migration.
## Verification
- `yarn test:iframe`: frozen historical contract/defect assertions and candidate
lifecycle assertions. Historical failures are not candidate acceptance.
- `yarn typecheck`: strict source and existing consumers; full package declaration
consumers are still a separate PKG-IFRAME-04 gate.
- `yarn build artplayer-tool-iframe`: normal main/legacy/ESM production output and
generated docs copies. Never hand-edit those files.
- `yarn test:browser test/browser/iframe.spec.js`: real same/cross-origin windows
in Chromium, Firefox and WebKit. This tool-only fixture creates no player.
- 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`.
Expected historical failures must be archived, not suppressed.
The existing local demo remains at
`http://localhost:8082/?libs=./uncompiled/artplayer-tool-iframe/index.js&example=iframe`.
Full demo/player integration, native devices and final release checks are later
gates; the controlled iframe fixture is not evidence that those have passed.
+5
View File
@@ -6,6 +6,11 @@ Iframe tool for ArtPlayer
[https://artplayer.org](https://artplayer.org/?libs=./uncompiled/artplayer-tool-iframe/index.js&example=iframe)
## Maintenance
See [ARCHITECTURE.md](ARCHITECTURE.md) for the TypeScript module map, request
cleanup, compatibility constraints, executable checks and remaining migration work.
## License
MIT © Harvey Zhao
@@ -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?(t.ArtplayerToolIframe=t(),define(function(){return t.ArtplayerToolIframe})):(e="undefined"!==typeof globalThis?globalThis:e||self).ArtplayerToolIframe=t()}(this,function(){"use strict";class e{static get iframe(){return window.top!==window}static postMessage({type:t,data:s,id:i=0}){if(!e.iframe)throw new Error('The "ArtplayerToolIframe.postMessage" method can only be used in iframe');window.parent.postMessage({type:t,data:s,id:i},"*")}static async onMessage(t){if(!e.iframe)throw new Error('The "ArtplayerToolIframe.onMessage" method can only be used in iframe');const{type:s,data:i,id:o}=t.data;if("commit"===s)try{if(i.match(/\bresolve\((.*?)\)/)){const t=`return new Promise(function(resolve){\n${i}\n})`,s=await new Function(t)();e.postMessage({type:"response",data:s,id:o})}else{const t=new Function(i)();e.postMessage({type:"response",data:t,id:o})}}catch(n){throw e.postMessage({type:"error",data:n.message,id:o}),n}}static inject(){if(!e.iframe)throw new Error('The "ArtplayerToolIframe.inject" method can only be used in iframe');e.postMessage({type:"inject"}),window.addEventListener("message",e.onMessage)}constructor({iframe:e,url:t}){if(e instanceof HTMLIFrameElement===!1)throw new TypeError('"option.iframe" needs to be a HTMLIFrameElement');if("string"!==typeof t)throw new TypeError('"option.url" needs to be a string');this.url=t,this.$iframe=e,this.promises={},this.injected=!1,this.destroyed=!1,this.messageCallback=()=>null,this.onMessage=this.onMessage.bind(this),window.addEventListener("message",this.onMessage),this.$iframe.src=this.url}onMessage(e){const{type:t,data:s,id:i}=e.data;if("inject"===t)this.injected=!0;this.promises[i]&&("error"===t?this.promises[i].reject(new Error(s)):this.promises[i].resove(s),delete this.promises[i]),this.messageCallback&&this.messageCallback({type:t,data:s})}postMessage({type:e,data:t}){return new Promise((s,i)=>{(function o(){if(this.destroyed)i(new Error("The instance has been destroyed"));else if(this.injected){const o=Date.now();this.promises[o]={resove:s,reject:i},this.$iframe.contentWindow.postMessage({type:e,data:t,id:o},"*")}else setTimeout(o.bind(this),200)}).call(this)})}commit(e){if("function"!==typeof e)throw new TypeError('"commit.callback" needs to be a function');const t=e.toString(),s=t.substring(t.indexOf("{")+1,t.lastIndexOf("}"));return this.postMessage({type:"commit",data:s})}message(e){if("function"!==typeof e)throw new TypeError('"message.callback" needs to be a function');this.messageCallback=e}destroy(){this.destroyed=!0,window.removeEventListener("message",this.onMessage)}}return e});
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.ArtplayerToolIframe=t(),define(function(){return e.ArtplayerToolIframe})):(e="undefined"!==typeof globalThis?globalThis:e||self).ArtplayerToolIframe=t()}(this,function(){"use strict";const e=new WeakMap;let t=0;class s{static get iframe(){return window.top!==window}static postMessage({type:e,data:t,id:o=0}){if(!s.iframe)throw new Error('The "ArtplayerToolIframe.postMessage" method can only be used in iframe');window.parent.postMessage({type:e,data:t,id:o},"*")}static async onMessage(e){if(!s.iframe)throw new Error('The "ArtplayerToolIframe.onMessage" method can only be used in iframe');const{type:t,data:o,id:i}=e.data;if("commit"===t)try{if(o.match(/\bresolve\((.*?)\)/)){const e=`return new Promise(function(resolve){\n${o}\n})`,t=await new Function(e)();s.postMessage({type:"response",data:t,id:i})}else{const e=new Function(o)();s.postMessage({type:"response",data:e,id:i})}}catch(r){throw s.postMessage({type:"error",data:r.message,id:i}),r}}static inject(){if(!s.iframe)throw new Error('The "ArtplayerToolIframe.inject" method can only be used in iframe');s.postMessage({type:"inject"}),window.addEventListener("message",s.onMessage)}constructor({iframe:e,url:t}){if(e instanceof HTMLIFrameElement===!1)throw new TypeError('"option.iframe" needs to be a HTMLIFrameElement');if("string"!==typeof t)throw new TypeError('"option.url" needs to be a string');this.url=t,this.$iframe=e,this.promises={},this.injected=!1,this.destroyed=!1,this.messageCallback=()=>null,this.onMessage=this.onMessage.bind(this),window.addEventListener("message",this.onMessage),this.$iframe.src=this.url}onMessage(e){if(this.destroyed)return;const{type:t,data:s,id:o}=e.data;if("inject"===t)this.injected=!0;void 0!==o&&Object.prototype.hasOwnProperty.call(this.promises,o)&&this.promises[o]&&("error"===t?this.promises[o].reject(new Error(s)):this.promises[o].resove(s),delete this.promises[o]),this.messageCallback&&this.messageCallback({type:t,data:s})}postMessage(s){return function(s,{type:o,data:i}){return new Promise((r,n)=>{if(s.destroyed)return void n(new Error("The instance has been destroyed"));const a=e.get(s)||new Set;e.set(s,a);const d=t=>!!a.delete(t)&&(void 0!==t.timer&&clearTimeout(t.timer),void 0!==t.id&&delete s.promises[t.id],a.size||e.delete(s),!0),c={callbacks:{resove(e){d(c)&&r(e)},reject(e){d(c)&&n(e)}}};a.add(c);const l=()=>{if(c.timer=void 0,a.has(c))if(s.destroyed)c.callbacks.reject(new Error("The instance has been destroyed"));else try{if(s.injected){const e=Math.max(Date.now(),t+1);t=e,c.id=e,s.promises[e]=c.callbacks,s.$iframe.contentWindow.postMessage({type:o,data:i,id:e},"*")}else c.timer=setTimeout(l,200)}catch(e){c.callbacks.reject(e)}};l()})}(this,s)}commit(e){if("function"!==typeof e)throw new TypeError('"commit.callback" needs to be a function');const t=e.toString(),s=t.substring(t.indexOf("{")+1,t.lastIndexOf("}"));return this.postMessage({type:"commit",data:s})}message(e){if("function"!==typeof e)throw new TypeError('"message.callback" needs to be a function');this.messageCallback=e}destroy(){this.destroyed=!0,window.removeEventListener("message",this.onMessage),function(t){const s=e.get(t);if(s)for(const e of[...s])e.callbacks.reject(new Error("The instance has been destroyed"))}(this)}}return s});
@@ -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?(t.ArtplayerToolIframe=t(),define(function(){return t.ArtplayerToolIframe})):(e="undefined"!==typeof globalThis?globalThis:e||self).ArtplayerToolIframe=t()}(this,function(){"use strict";class e{static get iframe(){return window.top!==window}static postMessage({type:t,data:s,id:o=0}){if(!e.iframe)throw new Error('The "ArtplayerToolIframe.postMessage" method can only be used in iframe');window.parent.postMessage({type:t,data:s,id:o},"*")}static onMessage(t){return s=this,o=null,i=function*(){if(!e.iframe)throw new Error('The "ArtplayerToolIframe.onMessage" method can only be used in iframe');const{type:s,data:o,id:i}=t.data;if("commit"===s)try{if(o.match(/\bresolve\((.*?)\)/)){const t=`return new Promise(function(resolve){\n${o}\n})`,s=yield new Function(t)();e.postMessage({type:"response",data:s,id:i})}else{const t=new Function(o)();e.postMessage({type:"response",data:t,id:i})}}catch(n){throw e.postMessage({type:"error",data:n.message,id:i}),n}},new Promise((e,t)=>{var n=e=>{try{a(i.next(e))}catch(s){t(s)}},r=e=>{try{a(i.throw(e))}catch(s){t(s)}},a=t=>t.done?e(t.value):Promise.resolve(t.value).then(n,r);a((i=i.apply(s,o)).next())});var s,o,i}static inject(){if(!e.iframe)throw new Error('The "ArtplayerToolIframe.inject" method can only be used in iframe');e.postMessage({type:"inject"}),window.addEventListener("message",e.onMessage)}constructor({iframe:e,url:t}){if(e instanceof HTMLIFrameElement===!1)throw new TypeError('"option.iframe" needs to be a HTMLIFrameElement');if("string"!==typeof t)throw new TypeError('"option.url" needs to be a string');this.url=t,this.$iframe=e,this.promises={},this.injected=!1,this.destroyed=!1,this.messageCallback=()=>null,this.onMessage=this.onMessage.bind(this),window.addEventListener("message",this.onMessage),this.$iframe.src=this.url}onMessage(e){const{type:t,data:s,id:o}=e.data;if("inject"===t)this.injected=!0;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({type:e,data:t}){return new Promise((s,o)=>{(function i(){if(this.destroyed)o(new Error("The instance has been destroyed"));else if(this.injected){const i=Date.now();this.promises[i]={resove:s,reject:o},this.$iframe.contentWindow.postMessage({type:e,data:t,id:i},"*")}else setTimeout(i.bind(this),200)}).call(this)})}commit(e){if("function"!==typeof e)throw new TypeError('"commit.callback" needs to be a function');const t=e.toString(),s=t.substring(t.indexOf("{")+1,t.lastIndexOf("}"));return this.postMessage({type:"commit",data:s})}message(e){if("function"!==typeof e)throw new TypeError('"message.callback" needs to be a function');this.messageCallback=e}destroy(){this.destroyed=!0,window.removeEventListener("message",this.onMessage)}}return e});
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.ArtplayerToolIframe=t(),define(function(){return e.ArtplayerToolIframe})):(e="undefined"!==typeof globalThis?globalThis:e||self).ArtplayerToolIframe=t()}(this,function(){"use strict";const e=new WeakMap;let t=0;class s{static get iframe(){return window.top!==window}static postMessage({type:e,data:t,id:o=0}){if(!s.iframe)throw new Error('The "ArtplayerToolIframe.postMessage" method can only be used in iframe');window.parent.postMessage({type:e,data:t,id:o},"*")}static onMessage(e){return t=this,o=null,r=function*(){if(!s.iframe)throw new Error('The "ArtplayerToolIframe.onMessage" method can only be used in iframe');const{type:t,data:o,id:r}=e.data;if("commit"===t)try{if(o.match(/\bresolve\((.*?)\)/)){const e=`return new Promise(function(resolve){\n${o}\n})`,t=yield new Function(e)();s.postMessage({type:"response",data:t,id:r})}else{const e=new Function(o)();s.postMessage({type:"response",data:e,id:r})}}catch(i){throw s.postMessage({type:"error",data:i.message,id:r}),i}},new Promise((e,s)=>{var i=e=>{try{a(r.next(e))}catch(t){s(t)}},n=e=>{try{a(r.throw(e))}catch(t){s(t)}},a=t=>t.done?e(t.value):Promise.resolve(t.value).then(i,n);a((r=r.apply(t,o)).next())});var t,o,r}static inject(){if(!s.iframe)throw new Error('The "ArtplayerToolIframe.inject" method can only be used in iframe');s.postMessage({type:"inject"}),window.addEventListener("message",s.onMessage)}constructor({iframe:e,url:t}){if(e instanceof HTMLIFrameElement===!1)throw new TypeError('"option.iframe" needs to be a HTMLIFrameElement');if("string"!==typeof t)throw new TypeError('"option.url" needs to be a string');this.url=t,this.$iframe=e,this.promises={},this.injected=!1,this.destroyed=!1,this.messageCallback=()=>null,this.onMessage=this.onMessage.bind(this),window.addEventListener("message",this.onMessage),this.$iframe.src=this.url}onMessage(e){if(this.destroyed)return;const{type:t,data:s,id:o}=e.data;if("inject"===t)this.injected=!0;void 0!==o&&Object.prototype.hasOwnProperty.call(this.promises,o)&&this.promises[o]&&("error"===t?this.promises[o].reject(new Error(s)):this.promises[o].resove(s),delete this.promises[o]),this.messageCallback&&this.messageCallback({type:t,data:s})}postMessage(s){return function(s,{type:o,data:r}){return new Promise((i,n)=>{if(s.destroyed)return void n(new Error("The instance has been destroyed"));const a=e.get(s)||new Set;e.set(s,a);const d=t=>!!a.delete(t)&&(void 0!==t.timer&&clearTimeout(t.timer),void 0!==t.id&&delete s.promises[t.id],a.size||e.delete(s),!0),c={callbacks:{resove(e){d(c)&&i(e)},reject(e){d(c)&&n(e)}}};a.add(c);const l=()=>{if(c.timer=void 0,a.has(c))if(s.destroyed)c.callbacks.reject(new Error("The instance has been destroyed"));else try{if(s.injected){const e=Math.max(Date.now(),t+1);t=e,c.id=e,s.promises[e]=c.callbacks,s.$iframe.contentWindow.postMessage({type:o,data:r,id:e},"*")}else c.timer=setTimeout(l,200)}catch(e){c.callbacks.reject(e)}};l()})}(this,s)}commit(e){if("function"!==typeof e)throw new TypeError('"commit.callback" needs to be a function');const t=e.toString(),s=t.substring(t.indexOf("{")+1,t.lastIndexOf("}"));return this.postMessage({type:"commit",data:s})}message(e){if("function"!==typeof e)throw new TypeError('"message.callback" needs to be a function');this.messageCallback=e}destroy(){this.destroyed=!0,window.removeEventListener("message",this.onMessage),function(t){const s=e.get(t);if(s)for(const e of[...s])e.callbacks.reject(new Error("The instance has been destroyed"))}(this)}}return s});
+72 -24
View File
@@ -4,6 +4,72 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
const requests = /* @__PURE__ */ new WeakMap();
let lastId = 0;
function postRequest(host, { type, data }) {
return new Promise((resolve, reject) => {
if (host.destroyed) {
reject(new Error("The instance has been destroyed"));
return;
}
const pending = requests.get(host) || /* @__PURE__ */ new Set();
requests.set(host, pending);
const finish = (request2) => {
if (!pending.delete(request2))
return false;
if (request2.timer !== void 0)
clearTimeout(request2.timer);
if (request2.id !== void 0)
delete host.promises[request2.id];
if (!pending.size)
requests.delete(host);
return true;
};
const request = {
callbacks: {
resove(value) {
if (finish(request))
resolve(value);
},
reject(error) {
if (finish(request))
reject(error);
}
}
};
pending.add(request);
const loop = () => {
request.timer = void 0;
if (!pending.has(request))
return;
if (host.destroyed) {
request.callbacks.reject(new Error("The instance has been destroyed"));
return;
}
try {
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 }, "*");
} else {
request.timer = setTimeout(loop, 200);
}
} catch (error) {
request.callbacks.reject(error);
}
};
loop();
});
}
function cancelRequests(host) {
const pending = requests.get(host);
if (!pending)
return;
for (const request of [...pending])
request.callbacks.reject(new Error("The instance has been destroyed"));
}
class ArtplayerToolIframe {
static get iframe() {
return window.top !== window;
@@ -71,13 +137,15 @@ ${data}
this.$iframe.src = this.url;
}
onMessage(event) {
if (this.destroyed)
return;
const { type, data, id } = event.data;
switch (type) {
case "inject":
this.injected = true;
break;
}
if (this.promises[id]) {
if (id !== void 0 && Object.prototype.hasOwnProperty.call(this.promises, id) && this.promises[id]) {
if (type === "error") {
this.promises[id].reject(new Error(data));
} else {
@@ -89,29 +157,8 @@ ${data}
this.messageCallback({ type, data });
}
}
postMessage({ type, data }) {
return new Promise((resove, reject) => {
(function loop() {
if (this.destroyed) {
reject(new Error("The instance has been destroyed"));
} else {
if (this.injected) {
const id = Date.now();
this.promises[id] = { resove, reject };
this.$iframe.contentWindow.postMessage(
{
type,
data,
id
},
"*"
);
} else {
setTimeout(loop.bind(this), 200);
}
}
}).call(this);
});
postMessage(message) {
return postRequest(this, message);
}
commit(callback) {
if (typeof callback !== "function") {
@@ -130,6 +177,7 @@ ${data}
destroy() {
this.destroyed = true;
window.removeEventListener("message", this.onMessage);
cancelRequests(this);
}
}
export {
@@ -1,9 +1,19 @@
import type { Callbacks, Packet } from './requests'
import { cancelRequests, postRequest } from './requests'
export default class ArtplayerToolIframe {
declare url: string
declare $iframe: HTMLIFrameElement
declare promises: Record<number, Callbacks>
declare injected: boolean
declare destroyed: boolean
declare messageCallback: ((...args: any[]) => any) | null
static get iframe() {
return window.top !== window
}
static postMessage({ type, data, id = 0 }) {
static postMessage({ type, data, id = 0 }: Packet) {
if (!ArtplayerToolIframe.iframe) {
throw new Error('The "ArtplayerToolIframe.postMessage" method can only be used in iframe')
}
@@ -18,7 +28,7 @@ export default class ArtplayerToolIframe {
)
}
static async onMessage(event) {
static async onMessage(event: MessageEvent<Packet>) {
if (!ArtplayerToolIframe.iframe) {
throw new Error('The "ArtplayerToolIframe.onMessage" method can only be used in iframe')
}
@@ -40,7 +50,7 @@ export default class ArtplayerToolIframe {
}
}
catch (error) {
ArtplayerToolIframe.postMessage({ type: 'error', data: error.message, id })
ArtplayerToolIframe.postMessage({ type: 'error', data: (error as { message: string }).message, id })
throw error
}
break
@@ -58,7 +68,7 @@ export default class ArtplayerToolIframe {
window.addEventListener('message', ArtplayerToolIframe.onMessage)
}
constructor({ iframe, url }) {
constructor({ iframe, url }: { iframe: HTMLIFrameElement, url: string }) {
if (iframe instanceof HTMLIFrameElement === false) {
throw new TypeError('"option.iframe" needs to be a HTMLIFrameElement')
}
@@ -78,7 +88,10 @@ export default class ArtplayerToolIframe {
this.$iframe.src = this.url
}
onMessage(event) {
onMessage(event: MessageEvent<Packet>) {
if (this.destroyed)
return
const { type, data, id } = event.data
switch (type) {
@@ -89,12 +102,12 @@ export default class ArtplayerToolIframe {
break
}
if (this.promises[id]) {
if (id !== undefined && Object.prototype.hasOwnProperty.call(this.promises, id) && this.promises[id]) {
if (type === 'error') {
this.promises[id].reject(new Error(data))
this.promises[id]!.reject(new Error(data))
}
else {
this.promises[id].resove(data)
this.promises[id]!.resove(data)
}
delete this.promises[id]
}
@@ -104,34 +117,11 @@ export default class ArtplayerToolIframe {
}
}
postMessage({ type, data }) {
return new Promise((resove, reject) => {
(function loop() {
if (this.destroyed) {
reject(new Error('The instance has been destroyed'))
}
else {
if (this.injected) {
const id = Date.now()
this.promises[id] = { resove, reject }
this.$iframe.contentWindow.postMessage(
{
type,
data,
id,
},
'*',
)
}
else {
setTimeout(loop.bind(this), 200)
}
}
}).call(this)
})
postMessage(message: Packet): Promise<any> {
return postRequest(this, message)
}
commit(callback) {
commit<T extends (...args: any[]) => any>(callback: T): Promise<ReturnType<T>> {
if (typeof callback !== 'function') {
throw new TypeError('"commit.callback" needs to be a function')
}
@@ -140,7 +130,7 @@ export default class ArtplayerToolIframe {
return this.postMessage({ type: 'commit', data: bodyString })
}
message(callback) {
message(callback: (...args: any[]) => any) {
if (typeof callback !== 'function') {
throw new TypeError('"message.callback" needs to be a function')
}
@@ -150,5 +140,6 @@ export default class ArtplayerToolIframe {
destroy() {
this.destroyed = true
window.removeEventListener('message', this.onMessage)
cancelRequests(this)
}
}
@@ -0,0 +1,94 @@
export interface Packet {
type: string
data?: any
id?: number
}
export interface Callbacks {
resove: (value?: any) => void
reject: (reason?: any) => void
}
export interface RequestHost {
$iframe: HTMLIFrameElement
promises: Record<number, Callbacks>
injected: boolean
destroyed: boolean
}
interface Pending {
id?: number
timer?: ReturnType<typeof setTimeout>
callbacks: Callbacks
}
const requests = new WeakMap<RequestHost, Set<Pending>>()
let lastId = 0
export function postRequest(host: RequestHost, { type, data }: Packet): Promise<any> {
return new Promise((resolve, reject) => {
if (host.destroyed) {
reject(new Error('The instance has been destroyed'))
return
}
const pending = requests.get(host) || new Set<Pending>()
requests.set(host, pending)
const finish = (request: Pending) => {
if (!pending.delete(request))
return false
if (request.timer !== undefined)
clearTimeout(request.timer)
if (request.id !== undefined)
delete host.promises[request.id]
if (!pending.size)
requests.delete(host)
return true
}
const request: Pending = {
callbacks: {
resove(value) {
if (finish(request))
resolve(value)
},
reject(error) {
if (finish(request))
reject(error)
},
},
}
pending.add(request)
const loop = () => {
request.timer = undefined
if (!pending.has(request))
return
if (host.destroyed) {
request.callbacks.reject(new Error('The instance has been destroyed'))
return
}
try {
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 }, '*')
}
else {
request.timer = setTimeout(loop, 200)
}
}
catch (error) {
request.callbacks.reject(error)
}
}
loop()
})
}
export function cancelRequests(host: RequestHost): void {
const pending = requests.get(host)
if (!pending)
return
for (const request of [...pending])
request.callbacks.reject(new Error('The instance has been destroyed'))
}
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "allowJs": false },
"include": ["src/**/*.ts", "types/**/*.d.ts"]
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,82 @@
# PKG-IFRAME-03 request ownership checkpoint (doing)
The old single JS file is now a strict TS public entry plus a request ownership
module. Entry methods and fields remain in place; the private WeakMap does not
add reflective instance keys. Source types are checked by a new package tsconfig,
without changing published declarations or installing dependencies. Package
ARCHITECTURE.md explains state, ownership, compatibility and follow-up work.
## Corrections and compatibility
| Boundary | Previous result | Candidate result |
| --- | --- | --- |
| Same millisecond / clock rollback | Callbacks overwrite each other or old IDs recur | Module-wide increasing numeric IDs preserve independent responses |
| Multiple instances, one frame | Each instance can settle from the same response ID | Instances from the same module allocate distinct IDs |
| Destroy sent/waiting request | Sent promise hangs; waiting poll remains until its next tick | Immediate rejection with the existing destroyed Error message, timers/registry released |
| Saved callback after destroy | Can settle or notify | Inert |
| Native send failure | Immediate rejection leaks record; delayed failure escapes callback | Same original error rejects in both paths, all owned state removed |
| Public resove/reject | Promise settles but its registry entry remains | Promise and corresponding owned registry entry released once |
| Inherited response ID | `toString` can be mistaken for a request and throws | Only own registry entries participate, generic message callback still runs |
These are deliberate fixes under API-02/03/04/05/12, not a new wire format.
Constructor validation, seven field descriptors/order, bound onMessage, callback
receiver/packet shape, numeric envelopes, `resove`, ordinary registry identity,
wildcard target origin, 200ms polling, generic non-error responses, function-body
commit serialization, child sync/resolve timing and child error channel remain.
An ID is now a correlation number rather than an exact clock reading. Callers
must handle pending request rejection on destroy; old promises hung indefinitely.
No automatic timeout, new public option, SDK or version change is introduced.
The counter is local to a library module; separately evaluated copies are not
coordinated. Source typing uses `any` only for the existing arbitrary message
payload/callback/result boundary; ownership and state are explicit. The child
error `.message` assertion intentionally retains the old error serialization,
including its existing behavior for unusual thrown values. The contentWindow
assertion does not add an availability guarantee: a null window throws inside the
send boundary and rejects the request rather than leaving it owned.
## Validation
The same 16 candidate Node assertions against the frozen workspace pass 5 and
fail 11; candidate source passes all 16. The unchanged candidate browser checks
against that old artifact pass 2 round trips and fail 8 lifecycle cases across
same/cross-origin Chromium. Full failure reports/results/traces are archived in
`refactor/.cache/iframe03-before-results`; Node log is `iframe03-before-node.log`.
This contrasts candidate acceptance with the separate historical defect assertions.
During the independent diff review, the new own-property guard was found to
throw for consumer-cleared `promises[id] = undefined`, whereas the old workspace
ignored it. `iframe03-empty-record-before.log` records that old/current contrast.
The guard now retains the old truthiness check as well; the existing regression
case covers this edge. Earlier passing CI/browser records predate this fix and
remain archived; final candidate builds and checks are rerun against the new bytes.
Final production/browser/CI results and hashes are recorded in the checkpoint
validation file after execution. No player is created by these iframe-only
fixtures; they do not establish full demo/core/media integration.
## Remaining work and rollback
PKG-IFRAME-03 remains doing. Navigation/reinjection, malformed packets, constructor
setup failure cleanup and the independent source/origin policy still need work.
IFRAME-LIFE-01, IFRAME-TRUST-01 and IFRAME-DIST-01 remain open. Trust policy has not
silently changed; current foreign-message behavior is still a known defect.
PKG-IFRAME-04 owns declaration/helper/old-name compatibility and 05/06 own complete
integration/distribution. This checkpoint is not release acceptance.
Rollback by reverting this checkpoint and rebuilding `artplayer-tool-iframe`
through the normal script. The frozen artifacts and previous evidence remain
immutable. Commit subject: `fix(iframe): [PKG-IFRAME-03] own pending requests and cancellation`.
Final source/main/legacy Node checks each pass 16. Final main browser matrix
passes 174 (144 historical defect assertions + 30 candidate acceptance), and
final legacy passes 30 candidate rows. All three engines execute real windows;
there are no skips/capability substitutes. CI passes 1505 (1331 unit + 14
engineering + 160 baseline), 44 repeated contracts and 326 production TS files;
3 import/SSR files pass after the final rebuild. All hashes match archived
reports and the final source/artifacts; docs copies are byte-identical.
One normal build attempt failed copying the legacy file into docs with Windows
copyfile UNKNOWN; the cause is not established. The failure log is retained,
and a normal build rerun succeeded before final checks. No manual artifact edit
or ignored build failure was used. Status stays doing and all three risks open.
+3 -2
View File
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 222 项,范围 22 个包及工作区/示例。
状态:todo 106 / doing 9 / blocked 0 / done 107 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 105 / doing 10 / blocked 0 / done 107 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -346,7 +346,7 @@
| --- | --- | --- | --- | --- | --- | --- |
| 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 | todo |
| PKG-IFRAME-03 | artplayer-tool-iframe<br>整理内部职责与资源 | PKG-IFRAME-02, CORE-02, BASE-07 | 请求注册/响应匹配/监听清理分离;origin/source 安全边界独立决策 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | doing |
| 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-06 | artplayer-tool-iframe<br>验证分发并同步文档 | PKG-IFRAME-05, ENG-07 | iframe.js、示例集成和原 script/class 导出验证;旧包名/额外helper深入口与新工具名的迁移结论 | tarball 入口/资源、类型、8082 demo 和 README 一致,有回退记录 | H | todo |
@@ -532,4 +532,5 @@
- PKG-MB-09: [记录](changes/2026-09-12-PKG-MB-09-native-pip-checkpoint.md) [记录](baselines/mb-native-pip-checkpoint.json) [记录](changes/2026-09-12-PKG-MB-09-sustained-playback-checkpoint.md) [记录](baselines/mb-sustained-validation.json) [记录](baselines/mb-sustained-media.json)
- PKG-IFRAME-01: [记录](changes/2026-09-12-PKG-IFRAME-01-contract.md) [记录](baselines/iframe-release.json) [记录](baselines/iframe-contract.md) [记录](baselines/iframe-contract-validation.json)
- PKG-IFRAME-02: [记录](changes/2026-09-12-PKG-IFRAME-02-behavior.md) [记录](baselines/iframe-behavior-validation.json)
- PKG-IFRAME-03: [记录](changes/2026-09-12-PKG-IFRAME-03-requests-checkpoint.md) [记录](baselines/iframe-requests-checkpoint.json)
- PKG-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)
+13
View File
@@ -1,5 +1,18 @@
# 进度与证据
## PKG-IFRAME-03 请求资源与TS入口检查点(doing)
入口和请求管理拆为2个严格TS模块,保留7个公开字段及resove/回调/消息协议。
修复ID冲突/回拨、跨实例串线、销毁悬挂、轮询发送错误和公开回调的资源泄漏。
同16断言旧版5通过/11失败,候选源码/main/legacy各16通过;旧版真实Chromium
候选断言2通过/8失败。最终main浏览器174(历史144+候选30)、legacy候选30通过,
完整CI1505与44重复契约、3导入/SSR文件通过,326生产TS。审查发现并修复空记录
兼容回归;一次Windows复制失败经正常重建恢复,全部旧/失败/最终报告保留。
见[变更](changes/2026-09-12-PKG-IFRAME-03-requests-checkpoint.md)和
[验证](baselines/iframe-requests-checkpoint.json)。222项:107 done、10 doing、105 todo。
本地检查点提交;IFRAME-03和三个风险仍未关闭。下一步导航/重新注入、构造失败清理、
畸形消息与独立来源信任策略,随后04处理公开声明与旧helper/分发兼容。
## PKG-IFRAME-02 完成:历史生命周期与真实窗口缺陷复现
52项Node和144项Chromium/Firefox/WebKit真实窗口检查通过,覆盖4份历史产物、同源/跨源。
+4 -2
View File
@@ -3775,14 +3775,16 @@
],
"status": "open",
"confirmation": "reproduced",
"compatibleResolution": "Reproduce same-tick request collision, pending injection, response errors and destroy behavior before changing internal ownership; preserve public promises[id].resove.",
"compatibleResolution": "Request ownership checkpoint fixes timestamp collisions, polling/send failures and terminal destruction while preserving public resove/registry/envelopes. Navigation/reinjection and setup failure cleanup remain for PKG-IFRAME-03; keep open until complete actual iframe lifecycle acceptance.",
"closureCriteria": "Old behavior and candidate fixes have deterministic race/resource tests plus actual iframe integration.",
"evidence": [
"refactor/baselines/iframe-contract.md",
"refactor/baselines/iframe-release.json",
"refactor/changes/2026-09-12-PKG-IFRAME-01-contract.md",
"refactor/baselines/iframe-behavior-validation.json",
"refactor/changes/2026-09-12-PKG-IFRAME-02-behavior.md"
"refactor/changes/2026-09-12-PKG-IFRAME-02-behavior.md",
"refactor/changes/2026-09-12-PKG-IFRAME-03-requests-checkpoint.md",
"refactor/baselines/iframe-requests-checkpoint.json"
]
},
{
+5 -2
View File
@@ -3656,11 +3656,14 @@
"CORE-02",
"BASE-07"
],
"status": "todo",
"status": "doing",
"risk": "H",
"deliverable": "请求注册/响应匹配/监听清理分离;origin/source 安全边界独立决策",
"acceptance": "结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过",
"evidence": []
"evidence": [
"changes/2026-09-12-PKG-IFRAME-03-requests-checkpoint.md",
"baselines/iframe-requests-checkpoint.json"
]
},
{
"id": "PKG-IFRAME-04",
+48 -12
View File
@@ -1,13 +1,20 @@
import process from 'node:process'
import { hash } from '../../refactor/scripts/releases.mjs'
import { iframeHistorical } from '../helpers/iframe.js'
import { iframeCandidate, iframeHistorical } from '../helpers/iframe.js'
import { expect, test } from './fixtures.js'
const implementations = (await iframeHistorical()).filter(item => item.global !== 'ArtplayerHelperIframe')
const candidate = { ...await iframeCandidate(), lifecycle: true }
const implementations = process.env.ARTPLAYER_IFRAME_LIFECYCLE_ONLY === '1'
? [candidate]
: [...(await iframeHistorical()).filter(item => item.global !== 'ArtplayerHelperIframe'), candidate]
for (const implementation of implementations) {
for (const relation of ['same-origin', 'cross-origin']) {
for (const scenario of ['round-trip', 'same-tick', 'destroy-pending', 'foreign-message', 'navigation', 'clone-failure']) {
test(`Iframe ${implementation.name}/${relation}: ${scenario} historical behavior`, async ({ page }, testInfo) => {
const scenarios = implementation.lifecycle
? ['round-trip', 'same-tick', 'destroy-pending', 'destroy-wait', 'clone-failure']
: ['round-trip', 'same-tick', 'destroy-pending', 'foreign-message', 'navigation', 'clone-failure']
for (const scenario of scenarios) {
test(`Iframe ${implementation.name}/${relation}: ${scenario} ${implementation.lifecycle ? 'lifecycle acceptance' : 'historical behavior'}`, async ({ page }, testInfo) => {
await page.goto('/test/player.html?core=published')
const parentOrigin = new URL(page.url()).origin
const alternate = parentOrigin.replace('127.0.0.1', 'localhost')
@@ -33,7 +40,7 @@ for (const implementation of implementations) {
</script>` }))
await page.route('**/iframe-baseline-foreign', route => route.fulfill({ contentType: 'text/html', body: '<!doctype html><script>parent.postMessage({type:"inject",data:"foreign"},"*")</script>' }))
await page.addScriptTag({ content: `(() => { const module = { exports: {} }; const exports = module.exports; ${implementation.code}; window.IframeFactory = module.exports.default || module.exports; })();` })
const manualInjection = scenario === 'foreign-message' || scenario === 'clone-failure'
const manualInjection = scenario === 'foreign-message' || scenario === 'clone-failure' || scenario === 'destroy-wait'
const url = `${childOrigin}/iframe-baseline-child?auto=${manualInjection ? 0 : 1}&epoch=1`
await page.evaluate((url) => {
window.packets = []
@@ -90,9 +97,11 @@ for (const implementation of implementations) {
}
finally { Date.now = now }
})
await expect.poll(() => page.evaluate(() => window.packets.filter(packet => packet.type === 'response' && packet.id === 1234).length)).toBe(2)
await expect.poll(() => page.evaluate(() => window.packets.filter(packet => packet.type === 'response').length)).toBe(2)
result = await page.evaluate(() => ({ states: window.states, pending: Object.keys(window.tool.promises).length }))
expect(result).toEqual({ states: { first: { status: 'pending' }, second: { status: 'resolved', value: 1 } }, pending: 0 })
expect(result).toEqual(implementation.lifecycle
? { states: { first: { status: 'resolved', value: 1 }, second: { status: 'resolved', value: 2 } }, pending: 0 }
: { states: { first: { status: 'pending' }, second: { status: 'resolved', value: 1 } }, pending: 0 })
}
else if (scenario === 'destroy-pending') {
await page.evaluate(() => window.track('held', window.tool.postMessage({ type: 'hold' })))
@@ -102,7 +111,22 @@ for (const implementation of implementations) {
await child.evaluate(() => window.Tool.postMessage({ type: 'response', data: 'late', id: window.held.id }))
await expect.poll(() => page.evaluate(() => window.packets.some(packet => packet.type === 'response' && packet.data === 'late'))).toBe(true)
result = await page.evaluate(() => ({ destroyed: window.tool.destroyed, state: window.states.held, pending: Object.keys(window.tool.promises).length }))
expect(result).toEqual({ destroyed: true, state: { status: 'pending' }, pending: 1 })
expect(result).toEqual(implementation.lifecycle
? { destroyed: true, state: { status: 'rejected', error: 'The instance has been destroyed' }, pending: 0 }
: { destroyed: true, state: { status: 'pending' }, pending: 1 })
}
else if (scenario === 'destroy-wait') {
result = await page.evaluate(async () => {
window.track('waiting', window.tool.postMessage({ type: 'hold' }))
window.tool.destroy()
await Promise.resolve()
return { state: window.states.waiting, pending: Object.keys(window.tool.promises).length }
})
expect(result).toEqual({ state: { status: 'rejected', error: 'The instance has been destroyed' }, pending: 0 })
const child = page.frames().find(frame => frame.url() === url)
await child.evaluate(() => window.Tool.inject())
await expect.poll(() => page.evaluate(() => window.packets.some(packet => packet.type === 'inject'))).toBe(true)
expect(await page.evaluate(() => window.tool.injected)).toBe(false)
}
else if (scenario === 'foreign-message') {
expect(await page.evaluate(() => window.tool.injected)).toBe(false)
@@ -143,9 +167,12 @@ for (const implementation of implementations) {
})
const child = page.frames().find(frame => frame.url() === url)
await child.evaluate(() => window.Tool.inject())
await expect.poll(() => page.evaluate(() => window.cloneErrors.length)).toBe(1)
if (implementation.lifecycle)
await expect.poll(() => page.evaluate(() => window.states.deferredClone.status)).toBe('rejected')
else
await expect.poll(() => page.evaluate(() => window.cloneErrors.length)).toBe(1)
result = await page.evaluate(async () => {
const deferredId = Object.keys(window.tool.promises)[0]
const deferredId = Object.keys(window.tool.promises)[0] || Date.now()
const now = Date.now
Date.now = () => Number(deferredId) + 1
let failure
@@ -156,7 +183,16 @@ for (const implementation of implementations) {
finally { Date.now = now }
return { failure, deferred: window.states.deferredClone, pending: Object.keys(window.tool.promises).length, uncaught: window.cloneErrors }
})
expect(result).toEqual({ failure: 'DataCloneError', deferred: { status: 'pending' }, pending: 2, uncaught: ['DataCloneError'] })
if (implementation.lifecycle) {
expect(result.failure).toBe('DataCloneError')
expect(result.deferred.status).toBe('rejected')
expect(result.deferred.error).toMatch(/clon/i)
expect(result.pending).toBe(0)
expect(result.uncaught).toEqual([])
}
else {
expect(result).toEqual({ failure: 'DataCloneError', deferred: { status: 'pending' }, pending: 2, uncaught: ['DataCloneError'] })
}
}
else {
const nextUrl = `${childOrigin}/iframe-baseline-child?auto=0&epoch=2`
@@ -177,7 +213,7 @@ for (const implementation of implementations) {
}
const packets = await page.evaluate(() => window.packets)
expect(packets.filter(packet => packet.fromChild).every(packet => packet.origin === childOrigin)).toBe(true)
await testInfo.attach('iframe-behavior', { contentType: 'application/json', body: JSON.stringify({ implementation: implementation.name, sha256: hash(implementation.code), relation, scenario, parentOrigin, childOrigin, foreignOrigin, result, packets, scope: 'Actual browser iframe WindowProxy/source/origin and postMessage. Local route fulfills immutable library bytes. Date.now is controlled for collision and distinct clone-failure IDs. Only the deliberate child error/DataCloneError is intercepted; known historical defects are asserted, not fixed or waived.' }) })
await testInfo.attach('iframe-behavior', { contentType: 'application/json', body: JSON.stringify({ implementation: implementation.name, sha256: hash(implementation.code), relation, scenario, parentOrigin, childOrigin, foreignOrigin, result, packets, lifecycleAcceptance: Boolean(implementation.lifecycle), scope: 'Actual browser iframe WindowProxy/source/origin and postMessage. Local route fulfills immutable library bytes. Date.now is controlled for collision and distinct clone-failure IDs. Only the deliberate child error/DataCloneError is intercepted. Historical defects are asserted for historical rows; lifecycle acceptance requires cleanup and absence of uncaught clone errors. Navigation and trust are not candidate acceptance yet.' }) })
}
finally {
await page.evaluate(() => {
+9
View File
@@ -1,6 +1,15 @@
import fs from 'node:fs'
import process from 'node:process'
import vm from 'node:vm'
import { verifyIframeContract } from '../../refactor/scripts/iframe-contract.mjs'
import { readMember } from '../../refactor/scripts/releases.mjs'
import { compilePackage } from './load.js'
export async function iframeCandidate() {
if (process.env.ARTPLAYER_IFRAME_BASELINE === '1')
return (await iframeHistorical()).find(item => item.name === 'workspace.js')
return { name: process.env.ARTPLAYER_IFRAME_ARTIFACT ? 'candidate-artifact' : 'candidate-source', global: 'ArtplayerToolIframe', namespace: false, code: process.env.ARTPLAYER_IFRAME_ARTIFACT ? fs.readFileSync(process.env.ARTPLAYER_IFRAME_ARTIFACT, 'utf8') : await compilePackage('artplayer-tool-iframe', 'umd') }
}
export async function iframeHistorical() {
const { baseline, archive, sources } = await verifyIframeContract()
+255
View File
@@ -0,0 +1,255 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Candidate assertions also run unchanged against the frozen workspace.
import test from 'node:test'
import { iframeCandidate, iframeEnvironment } from './helpers/iframe.js'
const implementation = await iframeCandidate()
const flush = () => new Promise(resolve => setImmediate(resolve))
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(injected = true) {
const env = iframeEnvironment(implementation)
const instance = new env.Factory({ iframe: new env.Frame(), url: '/first' })
if (injected)
env.dispatch({ type: 'inject' })
return { ...env, instance }
}
function tick(env) {
for (const [id, timer] of [...env.timers]) {
assert.equal(timer.delay, 200)
env.timers.delete(id)
timer.callback()
}
}
test('Iframe candidate preserves instance shape, descriptors, binding and listener-before-src order', () => {
const env = setup(false)
const instance = env.instance
assert.deepEqual(Object.getOwnPropertyNames(env.Factory.prototype), ['constructor', 'onMessage', 'postMessage', 'commit', 'message', 'destroy'])
assert.deepEqual(Object.keys(instance), ['url', '$iframe', 'promises', 'injected', 'destroyed', 'messageCallback', 'onMessage'])
for (const key of Object.keys(instance)) {
const descriptor = Object.getOwnPropertyDescriptor(instance, key)
assert(descriptor.writable && descriptor.enumerable && descriptor.configurable)
}
assert.deepEqual(env.order, ['listen:message', 'src'])
assert.equal(instance.messageCallback(), null)
const receiver = instance.onMessage
receiver({ data: { type: 'inject' } })
assert.equal(instance.injected, true)
assert.equal(instance.destroy(), undefined)
})
test('Iframe candidate preserves numeric envelopes, resove spelling and generic response callback receiver', async () => {
const env = setup()
const registry = env.instance.promises
const seen = []
env.instance.message(function (packet) {
seen.push({ receiver: this === env.instance, ...packet })
})
const pending = env.instance.postMessage({ type: 'custom-query', data: 3, id: 900 })
assert.deepEqual(Object.keys(registry[1234]), ['resove', 'reject'])
assert.deepEqual(JSON.parse(JSON.stringify(env.sent)), [{ packet: { type: 'custom-query', data: 3, id: 1234 }, origin: '*' }])
env.dispatch({ type: 'custom-result', data: 4, id: 1234 })
assert.equal(await pending, 4)
assert.equal(registry, env.instance.promises)
assert.deepEqual(Object.keys(registry), [])
assert.deepEqual(seen, [{ receiver: true, type: 'custom-result', data: 4 }])
env.instance.destroy()
})
test('Iframe candidate matches simultaneous requests independently and out of order', async () => {
const env = setup()
const first = observe(env.instance.postMessage({ type: 'one' }))
const second = observe(env.instance.postMessage({ type: 'two' }))
assert.deepEqual(env.sent.map(item => item.packet.id), [1234, 1235])
env.dispatch({ type: 'response', data: 2, id: 1235 })
env.dispatch({ type: 'response', data: 1, id: 1234 })
await flush()
assert.deepEqual([first, second], [{ status: 'resolved', value: 1 }, { status: 'resolved', value: 2 }])
assert.equal(Object.keys(env.instance.promises).length, 0)
env.instance.destroy()
})
test('Iframe candidate never reuses a settled ID when the clock moves backwards', async () => {
const env = setup()
const first = env.instance.postMessage({ type: 'one' })
env.dispatch({ type: 'response', data: 1, id: 1234 })
assert.equal(await first, 1)
env.now(1000)
const second = observe(env.instance.postMessage({ type: 'two' }))
assert.equal(env.sent.at(-1).packet.id, 1235)
env.dispatch({ type: 'response', data: 'duplicate', id: 1234 })
await flush()
assert.equal(second.status, 'pending')
env.dispatch({ type: 'response', data: 2, id: 1235 })
await flush()
assert.deepEqual(second, { status: 'resolved', value: 2 })
env.instance.destroy()
})
test('Iframe candidate separates two instances sharing the same iframe window', async () => {
const env = setup()
const second = new env.Factory({ iframe: env.instance.$iframe, url: '/first' })
env.dispatch({ type: 'inject' })
const one = observe(env.instance.postMessage({ type: 'one' }))
const two = observe(second.postMessage({ type: 'two' }))
assert.deepEqual(env.sent.map(item => item.packet.id), [1234, 1235])
env.dispatch({ type: 'response', data: 1, id: 1234 })
env.dispatch({ type: 'response', data: 2, id: 1235 })
await flush()
assert.deepEqual([one, two], [{ status: 'resolved', value: 1 }, { status: 'resolved', value: 2 }])
env.instance.destroy()
second.destroy()
})
test('Iframe candidate destroys sent requests immediately and ignores saved receivers', async () => {
const env = setup()
const state = observe(env.instance.postMessage({ type: 'one' }))
const receiver = env.instance.onMessage
let calls = 0
env.instance.message(() => calls++)
env.instance.destroy()
receiver({ data: { type: 'response', data: 'late', id: 1234 } })
await flush()
assert.deepEqual(state, { status: 'rejected', error: 'The instance has been destroyed' })
assert.equal(Object.keys(env.instance.promises).length, 0)
assert.equal(calls, 0)
assert.equal(env.handlers.get('message').size, 0)
assert.equal(env.instance.destroy(), undefined)
})
test('Iframe candidate destroys waiting requests immediately and cancels every polling timer', async () => {
const env = setup(false)
const states = Array.from({ length: 3 }, () => observe(env.instance.postMessage({ type: 'wait' })))
const late = [...env.timers.values()].map(timer => timer.callback)
env.instance.destroy()
await flush()
assert.equal(env.timers.size, 0)
assert(states.every(state => state.status === 'rejected' && state.error === 'The instance has been destroyed'))
late.forEach(callback => callback())
assert.equal(env.timers.size, 0)
assert.equal(env.sent.length, 0)
})
test('Iframe candidate preserves 200ms injection waiting and allocates unique IDs after handshake', async () => {
const env = setup(false)
const one = observe(env.instance.postMessage({ type: 'one' }))
const two = observe(env.instance.postMessage({ type: 'two' }))
tick(env)
assert.equal(env.sent.length, 0)
assert.equal(env.timers.size, 2)
env.dispatch({ type: 'inject' })
assert.equal(env.sent.length, 0)
tick(env)
assert.equal(env.timers.size, 0)
assert.deepEqual(env.sent.map(item => item.packet.id), [1234, 1235])
env.dispatch({ type: 'response', data: 1, id: 1234 })
env.dispatch({ type: 'error', data: 'remote failure', id: 1235 })
await flush()
assert.deepEqual([one, two], [{ status: 'resolved', value: 1 }, { status: 'rejected', error: 'remote failure' }])
env.instance.destroy()
})
for (const deferred of [false, true]) {
test(`Iframe candidate clears ${deferred ? 'deferred' : 'immediate'} send failure and preserves the exact rejection`, async () => {
const env = setup(!deferred)
const failure = new Error('clone failure')
env.instance.$iframe.contentWindow.postMessage = () => {
throw failure
}
const promise = env.instance.postMessage({ type: 'query' })
const rejected = assert.rejects(promise, error => error === failure)
if (deferred) {
env.dispatch({ type: 'inject' })
tick(env)
}
await rejected
assert.equal(Object.keys(env.instance.promises).length, 0)
assert.equal(env.timers.size, 0)
env.instance.destroy()
})
}
test('Iframe candidate rejects after destroy without allocating a request or timer', async () => {
const env = setup(false)
env.instance.destroy()
await assert.rejects(env.instance.postMessage({ type: 'late' }), /The instance has been destroyed/)
assert.equal(env.timers.size, 0)
assert.equal(env.sent.length, 0)
assert.equal(Object.keys(env.instance.promises).length, 0)
})
test('Iframe candidate permits public resove/reject callbacks and releases their owned requests', async () => {
const env = setup()
const first = env.instance.postMessage({ type: 'one' })
env.instance.promises[1234].resove(5)
assert.equal(await first, 5)
assert.equal(Object.keys(env.instance.promises).length, 0)
const second = env.instance.postMessage({ type: 'two' })
const rejected = assert.rejects(second, /external rejection/)
env.instance.promises[env.sent.at(-1).packet.id].reject(new Error('external rejection'))
await rejected
assert.equal(Object.keys(env.instance.promises).length, 0)
env.instance.destroy()
})
test('Iframe candidate cancels owned promises even when public records were removed by a consumer', async () => {
const env = setup()
const state = observe(env.instance.postMessage({ type: 'one' }))
delete env.instance.promises[1234]
env.instance.destroy()
await flush()
assert.deepEqual(state, { status: 'rejected', error: 'The instance has been destroyed' })
})
test('Iframe candidate ignores inherited request keys without changing generic message callbacks', () => {
const env = setup()
const seen = []
env.instance.message(packet => seen.push(packet.type))
assert.doesNotThrow(() => env.dispatch({ type: 'custom', id: 'toString' }))
assert.deepEqual(seen, ['custom'])
env.instance.promises[7] = undefined
assert.doesNotThrow(() => env.dispatch({ type: 'empty', id: 7 }))
assert.deepEqual(seen, ['custom', 'empty'])
env.instance.destroy()
})
test('Iframe candidate preserves commit serialization, synchronous response and async resolver behavior', async () => {
const parent = setup()
const promise = parent.instance.commit(() => {
return 7
})
const packet = parent.sent[0].packet
assert.equal(packet.data.trim(), 'return 7')
const child = iframeEnvironment(implementation, true)
const handling = child.Factory.onMessage({ data: packet })
assert.equal(child.sent[0].packet.data, 7)
await handling
parent.dispatch(child.sent[0].packet)
assert.equal(await promise, 7)
const async = child.Factory.onMessage({ data: { type: 'commit', data: 'resolve(8)', id: 18 } })
assert.equal(child.sent.length, 1)
await async
assert.equal(child.sent[1].packet.data, 8)
parent.instance.destroy()
})
test('Iframe candidate preserves child errors, injection repetition and argument validation', async () => {
const parent = setup()
assert.throws(() => new parent.Factory({ iframe: {}, url: '/' }), /needs to be a HTMLIFrameElement/)
assert.throws(() => parent.instance.commit(null), /needs to be a function/)
assert.throws(() => parent.instance.message(null), /needs to be a function/)
assert.throws(() => parent.Factory.inject(), /can only be used in iframe/)
const child = iframeEnvironment(implementation, true)
child.Factory.inject()
child.Factory.inject()
assert.equal(child.handlers.get('message').size, 1)
assert.deepEqual(child.sent.map(item => item.packet.id), [0, 0])
await assert.rejects(child.Factory.onMessage({ data: { type: 'commit', data: 'throw new Error("child failure")', id: 19 } }), /child failure/)
assert.equal(child.sent.at(-1).packet.type, 'error')
assert.equal(child.sent.at(-1).packet.data, 'child failure')
parent.instance.destroy()
})