mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-23 13:51:20 -08:00
refactor(iframe): [PKG-IFRAME-03] scope requests to active documents
This commit is contained in:
1 parent
b8cdafedd1
commit
1342de94ef
27 files changed
+10860
-69
No files matched your search
@@ -0,0 +1,92 @@
|
||||
import type { Packet } from './requests'
|
||||
import { sessionMetadata, sessionType, withSession } from './protocol'
|
||||
|
||||
interface ChildSession {
|
||||
owner: Document
|
||||
id: string
|
||||
acknowledged: boolean
|
||||
lifecycle: boolean
|
||||
receivers: Set<(event: MessageEvent<Packet>) => void>
|
||||
}
|
||||
|
||||
let session: ChildSession | undefined
|
||||
|
||||
export function prepareChildSession(): void {
|
||||
if (session?.owner === window.document && session)
|
||||
return
|
||||
const random = new Uint32Array(4)
|
||||
if (window.crypto?.getRandomValues)
|
||||
window.crypto.getRandomValues(random)
|
||||
else
|
||||
random.forEach((_, index) => random[index] = Math.floor(Math.random() * 0x100000000))
|
||||
session = { owner: window.document, id: `${Date.now()}-${Array.from(random).join('-')}`, acknowledged: false, lifecycle: false, receivers: new Set() }
|
||||
}
|
||||
|
||||
export function childEnvelope(packet: Packet): Packet {
|
||||
if (session && (packet.type === 'inject' || session.acknowledged))
|
||||
return withSession(packet, session.id, packet.type === 'inject' ? 'inject' : 'message')
|
||||
return packet
|
||||
}
|
||||
|
||||
export function consumeChildSession(packet: Packet): boolean {
|
||||
const metadata = sessionMetadata(packet)
|
||||
if (!metadata)
|
||||
return false
|
||||
if (packet.type === sessionType) {
|
||||
if (session && metadata.document === session.id && metadata.phase === 'ack')
|
||||
session.acknowledged = true
|
||||
return true
|
||||
}
|
||||
return !session || metadata.document !== session.id
|
||||
}
|
||||
|
||||
export function connectChildSession(receiver: (event: MessageEvent<Packet>) => void): void {
|
||||
const current = session!
|
||||
const added = !current.receivers.has(receiver)
|
||||
const installing = !current.lifecycle
|
||||
const notify = (phase: 'leave' | 'resume' | 'fragment', data?: unknown) => {
|
||||
if (!current.acknowledged)
|
||||
return
|
||||
try {
|
||||
window.parent.postMessage(withSession({ type: sessionType, data, id: 0 }, current.id, phase), '*')
|
||||
}
|
||||
catch {
|
||||
// The parent may have disappeared; this private lifecycle notification is best effort.
|
||||
}
|
||||
}
|
||||
const leave = () => notify('leave')
|
||||
const fragment = () => notify('fragment', window.location.href)
|
||||
const resume = (event: PageTransitionEvent) => {
|
||||
if (event.persisted)
|
||||
notify('resume')
|
||||
}
|
||||
try {
|
||||
window.addEventListener('message', receiver)
|
||||
current.receivers.add(receiver)
|
||||
if (!installing)
|
||||
return
|
||||
window.addEventListener('pagehide', leave)
|
||||
window.addEventListener('pageshow', resume)
|
||||
window.addEventListener('hashchange', fragment)
|
||||
current.lifecycle = true
|
||||
}
|
||||
catch (error) {
|
||||
const cleanups: (() => void)[] = []
|
||||
if (installing) {
|
||||
cleanups.push(() => window.removeEventListener('pagehide', leave))
|
||||
cleanups.push(() => window.removeEventListener('pageshow', resume))
|
||||
cleanups.push(() => window.removeEventListener('hashchange', fragment))
|
||||
}
|
||||
if (added)
|
||||
cleanups.push(() => window.removeEventListener('message', receiver))
|
||||
for (const cleanup of cleanups) {
|
||||
try {
|
||||
cleanup()
|
||||
}
|
||||
catch { /* Preserve the acquisition error while releasing the other listeners. */ }
|
||||
}
|
||||
if (added)
|
||||
current.receivers.delete(receiver)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Packet, RequestHost } from './requests'
|
||||
import { createNavigation } from './navigation'
|
||||
import { cancelRequests } from './requests'
|
||||
|
||||
interface ConnectionHost extends RequestHost {
|
||||
@@ -11,9 +12,29 @@ const connections = new WeakMap<ConnectionHost, () => void>()
|
||||
export function connect(host: ConnectionHost): void {
|
||||
const owner = window
|
||||
const receiver = host.onMessage
|
||||
connections.set(host, () => owner.removeEventListener('message', receiver))
|
||||
const cleanups = [() => owner.removeEventListener('message', receiver)]
|
||||
connections.set(host, () => {
|
||||
let failed = false
|
||||
let failure: unknown
|
||||
for (const cleanup of cleanups) {
|
||||
try {
|
||||
cleanup()
|
||||
}
|
||||
catch (error) {
|
||||
if (!failed) {
|
||||
failed = true
|
||||
failure = error
|
||||
}
|
||||
}
|
||||
}
|
||||
if (failed)
|
||||
throw failure
|
||||
})
|
||||
owner.addEventListener('message', receiver)
|
||||
host.$iframe.src = host.url
|
||||
const navigation = createNavigation(host)
|
||||
cleanups.push(navigation.dispose)
|
||||
navigation.start()
|
||||
}
|
||||
|
||||
export function releaseConnection(host: ConnectionHost): void {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Callbacks, Packet } from './requests'
|
||||
import { childEnvelope, connectChildSession, consumeChildSession, prepareChildSession } from './child-session'
|
||||
import { connect, releaseConnection } from './connection'
|
||||
import { consumeNavigation, prepareNavigation } from './navigation'
|
||||
import { acceptsMessage } from './protocol'
|
||||
import { postRequest } from './requests'
|
||||
|
||||
@@ -21,11 +23,11 @@ export default class ArtplayerToolIframe {
|
||||
}
|
||||
|
||||
window.parent.postMessage(
|
||||
{
|
||||
childEnvelope({
|
||||
type,
|
||||
data,
|
||||
id,
|
||||
},
|
||||
}),
|
||||
'*',
|
||||
)
|
||||
}
|
||||
@@ -35,7 +37,7 @@ export default class ArtplayerToolIframe {
|
||||
throw new Error('The "ArtplayerToolIframe.onMessage" method can only be used in iframe')
|
||||
}
|
||||
|
||||
if (!acceptsMessage(event, window.parent))
|
||||
if (!acceptsMessage(event, window.parent) || consumeChildSession(event.data))
|
||||
return
|
||||
|
||||
const { type, data, id } = event.data
|
||||
@@ -69,8 +71,9 @@ export default class ArtplayerToolIframe {
|
||||
throw new Error('The "ArtplayerToolIframe.inject" method can only be used in iframe')
|
||||
}
|
||||
|
||||
prepareChildSession()
|
||||
ArtplayerToolIframe.postMessage({ type: 'inject' })
|
||||
window.addEventListener('message', ArtplayerToolIframe.onMessage)
|
||||
connectChildSession(ArtplayerToolIframe.onMessage)
|
||||
}
|
||||
|
||||
constructor({ iframe, url }: { iframe: HTMLIFrameElement, url: string }) {
|
||||
@@ -105,7 +108,7 @@ export default class ArtplayerToolIframe {
|
||||
}
|
||||
|
||||
onMessage(event: MessageEvent<Packet>) {
|
||||
if (this.destroyed || !acceptsMessage(event, this.$iframe.contentWindow))
|
||||
if (this.destroyed || !acceptsMessage(event, this.$iframe.contentWindow) || consumeNavigation(this, event.data))
|
||||
return
|
||||
|
||||
const { type, data, id } = event.data
|
||||
@@ -134,6 +137,7 @@ export default class ArtplayerToolIframe {
|
||||
}
|
||||
|
||||
postMessage(message: Packet): Promise<any> {
|
||||
prepareNavigation(this)
|
||||
return postRequest(this, message)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { Packet, RequestHost } from './requests'
|
||||
import { sessionMetadata, sessionType, withSession } from './protocol'
|
||||
import { cancelRequests, captureRequests, setRequestBoundary } from './requests'
|
||||
|
||||
interface Navigation {
|
||||
source: string
|
||||
rawSource: string
|
||||
documentId?: string
|
||||
hasHandshake: boolean
|
||||
waiting: boolean
|
||||
observer?: MutationObserver
|
||||
cancelPrevious?: (reason: unknown) => void
|
||||
}
|
||||
|
||||
const navigations = new WeakMap<RequestHost, Navigation>()
|
||||
const withoutHash = (value: string) => value.split('#')[0]!
|
||||
function sourceOf(host: RequestHost): string {
|
||||
const frame = host.$iframe
|
||||
return frame.hasAttribute?.('srcdoc') ? `srcdoc:${frame.getAttribute('srcdoc')}` : `src:${withoutHash(frame.src || '')}`
|
||||
}
|
||||
|
||||
function rawSourceOf(host: RequestHost): string {
|
||||
const frame = host.$iframe
|
||||
return frame.hasAttribute?.('srcdoc') ? `srcdoc:${frame.getAttribute('srcdoc')}` : `src:${frame.getAttribute?.('src') ?? frame.src ?? ''}`
|
||||
}
|
||||
|
||||
function invalidate(host: RequestHost, state: Navigation): void {
|
||||
if (!state.waiting) {
|
||||
if (state.documentId)
|
||||
state.cancelPrevious = captureRequests(host)
|
||||
else
|
||||
cancelRequests(host, new Error('The iframe document has changed'))
|
||||
}
|
||||
state.waiting = true
|
||||
host.injected = false
|
||||
}
|
||||
|
||||
function leaveDocument(host: RequestHost, state: Navigation): void {
|
||||
if (state.cancelPrevious)
|
||||
state.cancelPrevious(new Error('The iframe document has changed'))
|
||||
else if (!state.waiting)
|
||||
cancelRequests(host, new Error('The iframe document has changed'))
|
||||
state.cancelPrevious = undefined
|
||||
state.waiting = true
|
||||
host.injected = false
|
||||
}
|
||||
|
||||
function sourceChanged(host: RequestHost, state: Navigation, records: MutationRecord[]): boolean {
|
||||
if (sourceOf(host) !== state.source)
|
||||
return true
|
||||
if (!state.documentId)
|
||||
return false
|
||||
if (rawSourceOf(host) !== state.rawSource)
|
||||
return true
|
||||
return records.some((record) => {
|
||||
if (record.attributeName === 'srcdoc')
|
||||
return true
|
||||
if (record.attributeName !== 'src' || host.$iframe.hasAttribute?.('srcdoc'))
|
||||
return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function observeSource(host: RequestHost, state: Navigation, records: MutationRecord[]): void {
|
||||
const rawSource = rawSourceOf(host)
|
||||
if (!records.length && rawSource === state.rawSource)
|
||||
return
|
||||
const changed = sourceChanged(host, state, records)
|
||||
state.rawSource = rawSource
|
||||
state.source = sourceOf(host)
|
||||
if (changed)
|
||||
invalidate(host, state)
|
||||
}
|
||||
|
||||
export function prepareNavigation(host: RequestHost): void {
|
||||
const state = navigations.get(host)
|
||||
if (!state || host.destroyed)
|
||||
return
|
||||
const records = state.observer?.takeRecords() || []
|
||||
observeSource(host, state, records)
|
||||
}
|
||||
|
||||
export function createNavigation(host: RequestHost): { start: () => void, dispose: () => void } {
|
||||
const state: Navigation = { source: sourceOf(host), rawSource: rawSourceOf(host), hasHandshake: false, waiting: false }
|
||||
navigations.set(host, state)
|
||||
setRequestBoundary(host, {
|
||||
prepare: () => prepareNavigation(host),
|
||||
envelope: packet => state.documentId ? withSession(packet, state.documentId, 'message') : packet,
|
||||
})
|
||||
return {
|
||||
start() {
|
||||
if (typeof window.MutationObserver !== 'function')
|
||||
return
|
||||
state.observer = new window.MutationObserver((records) => {
|
||||
if (host.destroyed)
|
||||
return
|
||||
observeSource(host, state, records)
|
||||
})
|
||||
state.observer.observe(host.$iframe, { attributes: true, attributeOldValue: true, attributeFilter: ['src', 'srcdoc'] })
|
||||
},
|
||||
dispose() {
|
||||
navigations.delete(host)
|
||||
setRequestBoundary(host, undefined)
|
||||
state.cancelPrevious = undefined
|
||||
state.observer?.disconnect()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function activate(host: RequestHost, state: Navigation, documentId: string): void {
|
||||
if (state.hasHandshake && state.documentId !== documentId) {
|
||||
if (state.cancelPrevious)
|
||||
state.cancelPrevious(new Error('The iframe document has changed'))
|
||||
else if (!state.waiting)
|
||||
cancelRequests(host, new Error('The iframe document has changed'))
|
||||
}
|
||||
state.cancelPrevious = undefined
|
||||
state.documentId = documentId
|
||||
state.hasHandshake = true
|
||||
state.waiting = false
|
||||
host.injected = true
|
||||
try {
|
||||
host.$iframe.contentWindow?.postMessage(withSession({ type: sessionType, data: undefined, id: 0 }, documentId, 'ack'), '*')
|
||||
}
|
||||
catch {
|
||||
// A failed private acknowledgement does not replace the public inject callback.
|
||||
}
|
||||
}
|
||||
|
||||
export function consumeNavigation(host: RequestHost, packet: Packet): boolean {
|
||||
const state = navigations.get(host)
|
||||
if (!state)
|
||||
return false
|
||||
prepareNavigation(host)
|
||||
const metadata = sessionMetadata(packet)
|
||||
if (metadata && packet.type === sessionType) {
|
||||
if (metadata.phase === 'resume') {
|
||||
activate(host, state, metadata.document)
|
||||
}
|
||||
else if (metadata.document === state.documentId) {
|
||||
if (metadata.phase === 'leave')
|
||||
leaveDocument(host, state)
|
||||
else if (metadata.phase === 'fragment' && state.cancelPrevious && !host.$iframe.hasAttribute?.('srcdoc') && packet.data === host.$iframe.src)
|
||||
activate(host, state, metadata.document)
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (packet.type === 'inject') {
|
||||
if (metadata?.phase === 'inject') {
|
||||
if (state.waiting && metadata.document === state.documentId)
|
||||
return true
|
||||
activate(host, state, metadata.document)
|
||||
}
|
||||
else {
|
||||
if (state.waiting) {
|
||||
state.cancelPrevious?.(new Error('The iframe document has changed'))
|
||||
state.cancelPrevious = undefined
|
||||
state.documentId = undefined
|
||||
}
|
||||
state.hasHandshake = true
|
||||
state.waiting = false
|
||||
}
|
||||
return false
|
||||
}
|
||||
return Boolean(metadata && ((state.waiting && !state.cancelPrevious) || metadata.document !== state.documentId))
|
||||
}
|
||||
@@ -1,5 +1,27 @@
|
||||
import type { Packet } from './requests'
|
||||
|
||||
export const sessionType = 'artplayer-tool-iframe:session'
|
||||
export type SessionPhase = 'inject' | 'message' | 'ack' | 'leave' | 'resume' | 'fragment'
|
||||
export interface SessionMetadata {
|
||||
version: 1
|
||||
document: string
|
||||
phase: SessionPhase
|
||||
}
|
||||
|
||||
interface SessionPacket extends Packet {
|
||||
__artplayerIframe: SessionMetadata
|
||||
}
|
||||
|
||||
export function sessionMetadata(packet: Packet): SessionMetadata | undefined {
|
||||
const value = (packet as Partial<SessionPacket>).__artplayerIframe
|
||||
if (value?.version === 1 && typeof value.document === 'string' && value.document && ['inject', 'message', 'ack', 'leave', 'resume', 'fragment'].includes(value.phase))
|
||||
return value
|
||||
}
|
||||
|
||||
export function withSession(packet: Packet, documentId: string, phase: SessionPhase): SessionPacket {
|
||||
return { ...packet, __artplayerIframe: { version: 1, document: documentId, phase } }
|
||||
}
|
||||
|
||||
export function acceptsMessage(event: MessageEvent<unknown>, peer: Window | null): event is MessageEvent<Packet> {
|
||||
if (event.source !== undefined && event.source !== null && event.source !== peer)
|
||||
return false
|
||||
|
||||
@@ -20,11 +20,24 @@ interface Pending {
|
||||
id?: number
|
||||
timer?: ReturnType<typeof setTimeout>
|
||||
callbacks: Callbacks
|
||||
cancel: (reason: unknown) => void
|
||||
}
|
||||
|
||||
const requests = new WeakMap<RequestHost, Set<Pending>>()
|
||||
interface RequestBoundary {
|
||||
prepare: () => void
|
||||
envelope: (packet: Packet) => Packet
|
||||
}
|
||||
const boundaries = new WeakMap<RequestHost, RequestBoundary>()
|
||||
let lastId = 0
|
||||
|
||||
export function setRequestBoundary(host: RequestHost, boundary: RequestBoundary | undefined): void {
|
||||
if (boundary)
|
||||
boundaries.set(host, boundary)
|
||||
else
|
||||
boundaries.delete(host)
|
||||
}
|
||||
|
||||
export function postRequest(host: RequestHost, { type, data }: Packet): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (host.destroyed) {
|
||||
@@ -55,6 +68,10 @@ export function postRequest(host: RequestHost, { type, data }: Packet): Promise<
|
||||
reject(error)
|
||||
},
|
||||
},
|
||||
cancel(error) {
|
||||
if (finish(request))
|
||||
reject(error)
|
||||
},
|
||||
}
|
||||
pending.add(request)
|
||||
const loop = () => {
|
||||
@@ -62,33 +79,45 @@ export function postRequest(host: RequestHost, { type, data }: Packet): Promise<
|
||||
if (!pending.has(request))
|
||||
return
|
||||
if (host.destroyed) {
|
||||
request.callbacks.reject(new Error('The instance has been destroyed'))
|
||||
request.cancel(new Error('The instance has been destroyed'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
boundaries.get(host)?.prepare()
|
||||
if (!pending.has(request))
|
||||
return
|
||||
if (host.injected) {
|
||||
const id = Math.max(Date.now(), lastId + 1)
|
||||
lastId = id
|
||||
request.id = id
|
||||
host.promises[id] = request.callbacks
|
||||
host.$iframe.contentWindow!.postMessage({ type, data, id }, '*')
|
||||
const packet = { type, data, id }
|
||||
host.$iframe.contentWindow!.postMessage(boundaries.get(host)?.envelope(packet) || packet, '*')
|
||||
}
|
||||
else {
|
||||
request.timer = setTimeout(loop, 200)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
request.callbacks.reject(error)
|
||||
request.cancel(error)
|
||||
}
|
||||
}
|
||||
loop()
|
||||
})
|
||||
}
|
||||
|
||||
export function cancelRequests(host: RequestHost): void {
|
||||
export function cancelRequests(host: RequestHost, reason = new Error('The instance has been destroyed')): void {
|
||||
const pending = requests.get(host)
|
||||
if (!pending)
|
||||
return
|
||||
for (const request of [...pending])
|
||||
request.callbacks.reject(new Error('The instance has been destroyed'))
|
||||
request.cancel(reason)
|
||||
}
|
||||
|
||||
export function captureRequests(host: RequestHost): (reason: unknown) => void {
|
||||
const captured = [...(requests.get(host) || [])]
|
||||
return (reason) => {
|
||||
for (const request of captured)
|
||||
request.cancel(reason)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user