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

@@ -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)
}
}