Crossdomain fixes

* fix tracker: testing memory clearing on iframe src change

fix tracker: testing memory clearing on iframe src change

* fixing inconsistent iframe stuff

* remove iframeoffset timestamp

* keep ts consistent

* fixing crossdomains
This commit is contained in:
Delirium 2024-10-10 13:09:40 +02:00 committed by GitHub
parent ba5b2f9b82
commit 72fe59bcbf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 191 additions and 70 deletions

View file

@ -6,9 +6,10 @@ import {
} from '@ant-design/icons';
import { Button, InputNumber, Popover } from 'antd';
import { Slider } from 'antd';
import cn from 'classnames';
import { observer } from 'mobx-react-lite';
import React, { useContext, useEffect, useRef, useState } from 'react';
import cn from 'classnames';
import { PlayerContext } from 'App/components/Session/playerContext';
function DropdownAudioPlayer({
@ -27,15 +28,24 @@ function DropdownAudioPlayer({
const fileLengths = useRef<Record<string, number>>({});
const { time = 0, speed = 1, playing, sessionStart } = store?.get() ?? {};
const files = React.useMemo(() => audioEvents.map((pa) => {
const data = pa.payload;
const nativeTs = data.timestamp
return {
url: data.url,
timestamp: data.timestamp,
start: nativeTs ? nativeTs : pa.timestamp - sessionStart,
};
}), [audioEvents.length, sessionStart])
const files = React.useMemo(
() =>
audioEvents.map((pa) => {
const data = pa.payload;
const nativeTs = data.timestamp;
const startTs = nativeTs
? nativeTs > sessionStart
? nativeTs - sessionStart
: nativeTs
: pa.timestamp - sessionStart;
return {
url: data.url,
timestamp: data.timestamp,
start: startTs,
};
}),
[audioEvents.length, sessionStart]
);
React.useEffect(() => {
Object.entries(audioRefs.current).forEach(([url, audio]) => {
@ -43,10 +53,10 @@ function DropdownAudioPlayer({
audio.loop = false;
audio.addEventListener('loadedmetadata', () => {
fileLengths.current[url] = audio.duration;
})
});
}
})
}, [audioRefs.current])
});
}, [audioRefs.current]);
const toggleMute = () => {
Object.values(audioRefs.current).forEach((audio) => {
@ -124,7 +134,7 @@ function DropdownAudioPlayer({
useEffect(() => {
const deltaMs = delta * 1000;
const deltaTime = Math.abs(lastPlayerTime.current - time - deltaMs)
const deltaTime = Math.abs(lastPlayerTime.current - time - deltaMs);
if (deltaTime >= 250) {
handleSeek(time);
}
@ -133,7 +143,7 @@ function DropdownAudioPlayer({
const file = files.find((f) => f.url === url);
const fileLength = fileLengths.current[url];
if (file) {
if (fileLength && (fileLength*1000)+file.start < time) {
if (fileLength && fileLength * 1000 + file.start < time) {
return;
}
if (time >= file.start) {
@ -154,8 +164,8 @@ function DropdownAudioPlayer({
if (audio) {
audio.muted = isMuted;
}
})
}, [isMuted])
});
}, [isMuted]);
useEffect(() => {
changePlaybackSpeed(speed);
@ -167,7 +177,7 @@ function DropdownAudioPlayer({
const file = files.find((f) => f.url === url);
const fileLength = fileLengths.current[url];
if (file) {
if (fileLength && (fileLength*1000)+file.start < time) {
if (fileLength && fileLength * 1000 + file.start < time) {
audio.pause();
return;
}
@ -182,7 +192,8 @@ function DropdownAudioPlayer({
setVolume(isMuted ? 0 : volume);
}, [playing]);
const buttonIcon = 'px-2 cursor-pointer border border-gray-light hover:border-main hover:text-main hover:z-10 h-fit'
const buttonIcon =
'px-2 cursor-pointer border border-gray-light hover:border-main hover:text-main hover:z-10 h-fit';
return (
<div className={'relative'}>
<div className={'flex items-center'} style={{ height: 24 }}>
@ -204,20 +215,14 @@ function DropdownAudioPlayer({
</div>
}
>
<div
className={
cn(buttonIcon, 'rounded-l')
}
>
<div className={cn(buttonIcon, 'rounded-l')}>
{isMuted ? <MutedOutlined /> : <SoundOutlined />}
</div>
</Popover>
<div
onClick={toggleVisible}
style={{ marginLeft: -1 }}
className={
cn(buttonIcon, 'rounded-r')
}
className={cn(buttonIcon, 'rounded-r')}
>
<CaretDownOutlined />
</div>

View file

@ -1,5 +1,4 @@
import logger from 'App/logger';
import { resolveURL } from "../../messages/rewriter/urlResolve";
import type Screen from '../../Screen/Screen';
import type { Message, SetNodeScroll } from '../../messages';
@ -32,6 +31,8 @@ export default class DOMManager extends ListWalker<Message> {
private readonly vTexts: Map<number, VText> = new Map() // map vs object here?
private readonly vElements: Map<number, VElement> = new Map()
private readonly olVRoots: Map<number, OnloadVRoot> = new Map()
/** required to keep track of iframes, frameId : vnodeId */
private readonly iframeRoots: Record<number, number> = {}
/** Constructed StyleSheets https://developer.mozilla.org/en-US/docs/Web/API/Document/adoptedStyleSheets
* as well as <style> tag owned StyleSheets
*/
@ -219,6 +220,10 @@ export default class DOMManager extends ListWalker<Message> {
if (['STYLE', 'style', 'LINK'].includes(msg.tag)) {
vElem.prioritized = true
}
if (this.vElements.has(msg.id)) {
logger.error("CreateElementNode: Node already exists", msg)
return
}
this.vElements.set(msg.id, vElem)
this.insertNode(msg)
this.removeBodyScroll(msg.id, vElem)
@ -316,6 +321,10 @@ export default class DOMManager extends ListWalker<Message> {
case MType.CreateIFrameDocument: {
const vElem = this.vElements.get(msg.frameID)
if (!vElem) { logger.error("CreateIFrameDocument: Node not found", msg); return }
if (this.iframeRoots[msg.frameID] && !this.olVRoots.has(msg.id)) {
this.olVRoots.delete(this.iframeRoots[msg.frameID])
}
this.iframeRoots[msg.frameID] = msg.id
const vRoot = OnloadVRoot.fromVElement(vElem)
vRoot.catch(e => logger.warn(e, msg))
this.olVRoots.set(msg.id, vRoot)

View file

@ -1,7 +1,7 @@
{
"name": "@openreplay/tracker",
"description": "The OpenReplay tracker main package",
"version": "14.0.10-beta.9",
"version": "14.0.10-beta.13",
"keywords": [
"logging",
"replay"

View file

@ -167,6 +167,12 @@ type AppOptions = {
}
network?: NetworkOptions
/**
* use this flag if you're using Angular
* basically goes around window.Zone api changes to mutation observer
* and event listeners
* */
angularMode?: boolean
} & WebworkerOptions &
SessOptions
@ -312,6 +318,7 @@ export default class App {
__save_canvas_locally: false,
useAnimationFrame: false,
},
angularMode: false,
}
this.options = simpleMerge(defaultOptions, options)
@ -329,7 +336,7 @@ export default class App {
this.localStorage = this.options.localStorage ?? window.localStorage
this.sessionStorage = this.options.sessionStorage ?? window.sessionStorage
this.sanitizer = new Sanitizer(this, options)
this.nodes = new Nodes(this.options.node_id)
this.nodes = new Nodes(this.options.node_id, Boolean(options.angularMode))
this.observer = new Observer(this, options)
this.ticker = new Ticker(this)
this.ticker.attach(() => this.commit())
@ -366,6 +373,7 @@ export default class App {
window.parent.postMessage(
{
line: proto.polling,
context: this.contextId,
},
'*',
)
@ -444,7 +452,6 @@ export default class App {
this.frameOderNumber = data.frameOrderNumber
this.debug.log('starting iframe tracking', data)
this.allowAppStart()
this.delay = data.frameTimeOffset
}
if (data.line === proto.killIframe) {
if (this.active()) {
@ -453,7 +460,11 @@ export default class App {
}
}
trackedFrames: number[] = []
/**
* context ids for iframes,
* order is not so important as long as its consistent
* */
trackedFrames: string[] = []
crossDomainIframeListener = (event: MessageEvent) => {
if (!this.active() || event.source === window) return
const { data } = event
@ -468,24 +479,34 @@ export default class App {
return console.error('Couldnt connect to event.source for child iframe tracking')
}
const id = await this.checkNodeId(pageIframes, event.source)
if (id && !this.trackedFrames.includes(id)) {
if (id && !this.trackedFrames.includes(data.context)) {
try {
this.trackedFrames.push(id)
this.trackedFrames.push(data.context)
await this.waitStarted()
const token = this.session.getSessionToken()
const order = this.trackedFrames.findIndex((f) => f === data.context) + 1
if (order === 0) {
this.debug.error(
'Couldnt get order number for iframe',
data.context,
this.trackedFrames,
)
}
const iframeData = {
line: proto.iframeId,
context: this.contextId,
id,
token,
frameOrderNumber: this.trackedFrames.length,
frameTimeOffset: this.timestamp(),
// since indexes go from 0 we +1
frameOrderNumber: order,
}
this.debug.log('Got child frame signal; nodeId', id, event.source, iframeData)
// @ts-ignore
event.source?.postMessage(iframeData, '*')
} catch (e) {
console.error(e)
}
} else {
this.debug.log('Couldnt get node id for iframe', event.source, pageIframes)
}
}
void signalId()
@ -541,25 +562,42 @@ export default class App {
this.messages.push(...mappedMessages)
}
if (data.line === proto.polling) {
if (!this.pollingQueue.length) {
if (!this.pollingQueue.order.length) {
return
}
while (this.pollingQueue.length) {
const msg = this.pollingQueue.shift()
const nextCommand = this.pollingQueue.order[0]
if (this.pollingQueue[nextCommand].includes(data.context)) {
this.pollingQueue[nextCommand] = this.pollingQueue[nextCommand].filter(
(c: string) => c !== data.context,
)
// @ts-ignore
event.source?.postMessage({ line: msg }, '*')
event.source?.postMessage({ line: nextCommand }, '*')
if (this.pollingQueue[nextCommand].length === 0) {
this.pollingQueue.order.shift()
}
}
}
}
pollingQueue: string[] = []
/**
* { command : [remaining iframes] }
* + order of commands
**/
pollingQueue: Record<string, any> = {
order: [],
}
private readonly addCommand = (cmd: string) => {
this.pollingQueue.order.push(cmd)
this.pollingQueue[cmd] = [...this.trackedFrames]
}
public bootChildrenFrames = async () => {
await this.waitStarted()
this.pollingQueue.push(proto.startIframe)
this.addCommand(proto.startIframe)
}
public killChildrenFrames = () => {
this.pollingQueue.push(proto.killIframe)
this.addCommand(proto.killIframe)
}
signalIframeTracker = () => {
@ -573,6 +611,7 @@ export default class App {
},
this.options.crossdomain?.parentDomain ?? '*',
)
console.log('trying to signal to parent', n)
setTimeout(() => {
if (!this.checkStatus() && n < 100) {
void signalToParent(n + 1)
@ -852,9 +891,13 @@ export default class App {
}
const createListener = () =>
target ? createEventListener(target, type, listener, useCapture) : null
target
? createEventListener(target, type, listener, useCapture, this.options.angularMode)
: null
const deleteListener = () =>
target ? deleteEventListener(target, type, listener, useCapture) : null
target
? deleteEventListener(target, type, listener, useCapture, this.options.angularMode)
: null
this.attachStartCallback(createListener, useSafe)
this.attachStopCallback(deleteListener, useSafe)

View file

@ -10,10 +10,13 @@ export default class Nodes {
private readonly elementListeners: Map<number, Array<ElementListener>> = new Map()
private nextNodeId = 0
constructor(private readonly node_id: string) {}
constructor(
private readonly node_id: string,
private readonly angularMode: boolean,
) {}
syntheticMode(frameOrder: number) {
const maxSafeNumber = 9007199254740900
const maxSafeNumber = Number.MAX_SAFE_INTEGER
const placeholderSize = 99999999
const nextFrameId = placeholderSize * frameOrder
// I highly doubt that this will ever happen,
@ -33,12 +36,12 @@ export default class Nodes {
this.nodes.forEach((node) => cb(node))
}
attachNodeListener(node: Node, type: string, listener: EventListener, useCapture = true): void {
attachNodeListener = (node: Node, type: string, listener: EventListener, useCapture = true): void => {
const id = this.getID(node)
if (id === undefined) {
return
}
createEventListener(node, type, listener, useCapture)
createEventListener(node, type, listener, useCapture, this.angularMode)
let listeners = this.elementListeners.get(id)
if (listeners === undefined) {
listeners = []
@ -70,7 +73,7 @@ export default class Nodes {
if (listeners !== undefined) {
this.elementListeners.delete(id)
listeners.forEach((listener) =>
deleteEventListener(node, listener[0], listener[1], listener[2]),
deleteEventListener(node, listener[0], listener[1], listener[2], this.angularMode),
)
}
this.totalNodeAmount--

View file

@ -19,13 +19,13 @@ export default class IFrameObserver extends Observer {
})
}
syntheticObserve(selfId: number, doc: Document) {
syntheticObserve(rootNodeId: number, doc: Document) {
this.observeRoot(doc, (docID) => {
if (docID === undefined) {
this.app.debug.log('OpenReplay: Iframe document not bound')
return
}
this.app.send(CreateIFrameDocument(selfId, docID))
this.app.send(CreateIFrameDocument(rootNodeId, docID))
})
}
}

View file

@ -1,4 +1,4 @@
import { createMutationObserver, ngSafeBrowserMethod } from '../../utils.js'
import { createMutationObserver } from '../../utils.js'
import {
RemoveNodeAttribute,
SetNodeAttributeURLBased,
@ -77,13 +77,11 @@ export default abstract class Observer {
// mutations order is sequential
const target = mutation.target
const type = mutation.type
if (!isObservable(target)) {
continue
}
if (type === 'childList') {
for (let i = 0; i < mutation.removedNodes.length; i++) {
// Should be the same as bindTree(mutation.removedNodes[i]), but logic needs to be be untied
if (isObservable(mutation.removedNodes[i])) {
this.bindNode(mutation.removedNodes[i])
}
@ -105,6 +103,9 @@ export default abstract class Observer {
if (name === null) {
continue
}
if (target instanceof HTMLIFrameElement && name === 'src') {
this.handleIframeSrcChange(target)
}
let attr = this.attributesMap.get(id)
if (attr === undefined) {
this.attributesMap.set(id, (attr = new Set()))
@ -114,13 +115,14 @@ export default abstract class Observer {
}
if (type === 'characterData') {
this.textSet.add(id)
continue
}
}
this.commitNodes()
}) as MutationCallback,
this.app.options.angularMode,
)
}
private clear(): void {
this.commited.length = 0
this.recents.clear()
@ -129,10 +131,49 @@ export default abstract class Observer {
this.textSet.clear()
}
/**
* Unbinds the removed nodes in case of iframe src change.
*/
private handleIframeSrcChange(iframe: HTMLIFrameElement): void {
const oldContentDocument = iframe.contentDocument
if (oldContentDocument) {
const id = this.app.nodes.getID(oldContentDocument)
if (id !== undefined) {
const walker = document.createTreeWalker(
oldContentDocument,
NodeFilter.SHOW_ELEMENT + NodeFilter.SHOW_TEXT,
{
acceptNode: (node) =>
isIgnored(node) || this.app.nodes.getID(node) === undefined
? NodeFilter.FILTER_REJECT
: NodeFilter.FILTER_ACCEPT,
},
// @ts-ignore
false,
)
let removed = 0
const totalBeforeRemove = this.app.nodes.getNodeCount()
while (walker.nextNode()) {
if (!iframe.contentDocument.contains(walker.currentNode)) {
removed += 1
this.app.nodes.unregisterNode(walker.currentNode)
}
}
const removedPercent = Math.floor((removed / totalBeforeRemove) * 100)
if (removedPercent > 30) {
this.app.send(UnbindNodes(removedPercent))
}
}
}
}
private sendNodeAttribute(id: number, node: Element, name: string, value: string | null): void {
if (isSVGElement(node)) {
if (name.substr(0, 6) === 'xlink:') {
name = name.substr(6)
if (name.substring(0, 6) === 'xlink:') {
name = name.substring(6)
}
if (value === null) {
this.app.send(RemoveNodeAttribute(id, name))
@ -152,7 +193,7 @@ export default abstract class Observer {
name === 'integrity' ||
name === 'crossorigin' ||
name === 'autocomplete' ||
name.substr(0, 2) === 'on'
name.substring(0, 2) === 'on'
) {
return
}
@ -357,6 +398,7 @@ export default abstract class Observer {
}
return true
}
private commitNode(id: number): boolean {
const node = this.app.nodes.getNode(id)
if (node === undefined) {
@ -368,6 +410,7 @@ export default abstract class Observer {
}
return (this.commited[id] = this._commitNode(id, node))
}
private commitNodes(isStart = false): void {
let node
this.recents.forEach((type, id) => {

View file

@ -1,6 +1,5 @@
import Observer from './observer.js'
import { isElementNode, hasTag } from '../guards.js'
import Network from '../../modules/network.js'
import IFrameObserver from './iframe_observer.js'
import ShadowRootObserver from './shadow_root_observer.js'
@ -140,7 +139,7 @@ export default class TopObserver extends Observer {
)
}
crossdomainObserve(selfId: number, frameOder: number) {
crossdomainObserve(rootNodeId: number, frameOder: number) {
const observer = this
Element.prototype.attachShadow = function () {
// eslint-disable-next-line
@ -152,7 +151,7 @@ export default class TopObserver extends Observer {
this.app.nodes.syntheticMode(frameOder)
const iframeObserver = new IFrameObserver(this.app)
this.iframeObservers.push(iframeObserver)
iframeObserver.syntheticObserve(selfId, window.document)
iframeObserver.syntheticObserve(rootNodeId, window.document)
}
disconnect() {

View file

@ -99,6 +99,7 @@ export default function (app: App): void {
}
}
}) as MutationCallback,
app.options.angularMode,
)
app.attachStopCallback(() => {

View file

@ -132,9 +132,13 @@ export function ngSafeBrowserMethod(method: string): string {
: method
}
export function createMutationObserver(cb: MutationCallback) {
const mObserver = ngSafeBrowserMethod('MutationObserver') as 'MutationObserver'
return new window[mObserver](cb)
export function createMutationObserver(cb: MutationCallback, angularMode?: boolean) {
if (angularMode) {
const mObserver = ngSafeBrowserMethod('MutationObserver') as 'MutationObserver'
return new window[mObserver](cb)
} else {
return new MutationObserver(cb)
}
}
export function createEventListener(
@ -142,16 +146,24 @@ export function createEventListener(
event: string,
cb: EventListenerOrEventListenerObject,
capture?: boolean,
angularMode?: boolean,
) {
const safeAddEventListener = ngSafeBrowserMethod('addEventListener') as 'addEventListener'
let safeAddEventListener: 'addEventListener'
if (angularMode) {
safeAddEventListener = ngSafeBrowserMethod('addEventListener') as 'addEventListener'
} else {
safeAddEventListener = 'addEventListener'
}
try {
target[safeAddEventListener](event, cb, capture)
target.addEventListener(event, cb, capture)
} catch (e) {
const msg = e.message
console.error(
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
`Openreplay: ${msg}; if this error is caused by an IframeObserver, ignore it`,
event,
target,
)
}
}
@ -161,18 +173,24 @@ export function deleteEventListener(
event: string,
cb: EventListenerOrEventListenerObject,
capture?: boolean,
angularMode?: boolean,
) {
const safeRemoveEventListener = ngSafeBrowserMethod(
'removeEventListener',
) as 'removeEventListener'
let safeRemoveEventListener: 'removeEventListener'
if (angularMode) {
safeRemoveEventListener = ngSafeBrowserMethod('removeEventListener') as 'removeEventListener'
} else {
safeRemoveEventListener = 'removeEventListener'
}
try {
target[safeRemoveEventListener](event, cb, capture)
target.removeEventListener(event, cb, capture)
} catch (e) {
const msg = e.message
console.error(
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
`Openreplay: ${msg}; if this error is caused by an IframeObserver, ignore it`,
event,
target,
)
}
}