port tracker-14 fixes to latest (#2645)

This commit is contained in:
Delirium 2024-10-10 14:21:56 +02:00 committed by GitHub
parent 111e9c6474
commit 28a9b53d05
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 451 additions and 212 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) => {
@ -125,7 +135,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);
}
@ -134,7 +144,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) {
@ -155,8 +165,8 @@ function DropdownAudioPlayer({
if (audio) {
audio.muted = isMuted;
}
})
}, [isMuted])
});
}, [isMuted]);
useEffect(() => {
changePlaybackSpeed(speed);
@ -168,7 +178,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;
}
@ -183,7 +193,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 }}>
@ -205,20 +216,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

@ -52,6 +52,12 @@ export interface StartOptions {
forceNew?: boolean
sessionHash?: string
assistOnly?: boolean
/**
* @deprecated We strongly advise to use .start().then instead.
*
* This method is kept for snippet compatibility only
* */
startCallback?: (result: StartPromiseReturn) => void
}
interface OnStartInfo {
@ -161,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
@ -185,12 +197,14 @@ const proto = {
resp: 'never-gonna-let-you-down',
// regenerating id (copied other tab)
reg: 'never-gonna-run-around-and-desert-you',
// tracker inside a child iframe
iframeSignal: 'never-gonna-make-you-cry',
// getting node id for child iframe
iframeId: 'never-gonna-say-goodbye',
// batch of messages from an iframe window
iframeBatch: 'never-gonna-tell-a-lie-and-hurt-you',
iframeSignal: 'tracker inside a child iframe',
iframeId: 'getting node id for child iframe',
iframeBatch: 'batch of messages from an iframe window',
parentAlive: 'signal that parent is live',
killIframe: 'stop tracker inside frame',
startIframe: 'start tracker inside frame',
// checking updates
polling: 'hello-how-are-you-im-under-the-water-please-help-me',
} as const
export default class App {
@ -237,7 +251,6 @@ export default class App {
private rootId: number | null = null
private pageFrames: HTMLIFrameElement[] = []
private frameOderNumber = 0
private readonly initialHostName = location.hostname
private features = {
'feature-flags': true,
'usability-test': true,
@ -248,7 +261,7 @@ export default class App {
sessionToken: string | undefined,
options: Partial<Options>,
private readonly signalError: (error: string, apis: string[]) => void,
private readonly insideIframe: boolean,
public readonly insideIframe: boolean,
) {
this.contextId = Math.random().toString(36).slice(2)
this.projectKey = projectKey
@ -305,6 +318,7 @@ export default class App {
__save_canvas_locally: false,
useAnimationFrame: false,
},
angularMode: false,
}
this.options = simpleMerge(defaultOptions, options)
@ -322,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())
@ -348,136 +362,31 @@ export default class App {
this.session.applySessionHash(sessionToken)
}
this.initWorker()
const thisTab = this.session.getTabId()
if (this.insideIframe) {
/**
* listen for messages from parent window, so we can signal that we're alive
* */
window.addEventListener('message', this.parentCrossDomainFrameListener)
setInterval(() => {
window.parent.postMessage(
{
line: proto.polling,
context: this.contextId,
},
'*',
)
}, 250)
} else {
this.initWorker()
}
if (!this.insideIframe) {
/**
* if we get a signal from child iframes, we check for their node_id and send it back,
* so they can act as if it was just a same-domain iframe
* */
let crossdomainFrameCount = 0
const catchIframeMessage = (event: MessageEvent) => {
const { data } = event
if (data.line === proto.iframeSignal) {
const childIframeDomain = data.domain
const pageIframes = Array.from(document.querySelectorAll('iframe'))
this.pageFrames = pageIframes
const signalId = async () => {
let tries = 0
while (tries < 10) {
const id = this.checkNodeId(pageIframes, childIframeDomain)
if (id) {
this.waitStarted()
.then(() => {
crossdomainFrameCount++
const token = this.session.getSessionToken()
const iframeData = {
line: proto.iframeId,
context: this.contextId,
domain: childIframeDomain,
id,
token,
frameOrderNumber: crossdomainFrameCount,
}
this.debug.log('iframe_data', iframeData)
// @ts-ignore
event.source?.postMessage(iframeData, '*')
})
.catch(console.error)
tries = 10
break
}
tries++
await delay(100)
}
}
void signalId()
}
/**
* proxying messages from iframe to main body, so they can be in one batch (same indexes, etc)
* plus we rewrite some of the messages to be relative to the main context/window
* */
if (data.line === proto.iframeBatch) {
const msgBatch = data.messages
const mappedMessages: Message[] = msgBatch.map((msg: Message) => {
if (msg[0] === MType.MouseMove) {
let fixedMessage = msg
this.pageFrames.forEach((frame) => {
if (frame.dataset.domain === event.data.domain) {
const [type, x, y] = msg
const { left, top } = frame.getBoundingClientRect()
fixedMessage = [type, x + left, y + top]
}
})
return fixedMessage
}
if (msg[0] === MType.MouseClick) {
let fixedMessage = msg
this.pageFrames.forEach((frame) => {
if (frame.dataset.domain === event.data.domain) {
const [type, id, hesitationTime, label, selector, normX, normY] = msg
const { left, top, width, height } = frame.getBoundingClientRect()
const contentWidth = document.documentElement.scrollWidth
const contentHeight = document.documentElement.scrollHeight
// (normalizedX * frameWidth + frameLeftOffset)/docSize
const fullX = (normX / 100) * width + left
const fullY = (normY / 100) * height + top
const fixedX = fullX / contentWidth
const fixedY = fullY / contentHeight
fixedMessage = [
type,
id,
hesitationTime,
label,
selector,
Math.round(fixedX * 1e3) / 1e1,
Math.round(fixedY * 1e3) / 1e1,
]
}
})
return fixedMessage
}
return msg
})
this.messages.push(...mappedMessages)
}
}
window.addEventListener('message', catchIframeMessage)
this.attachStopCallback(() => {
window.removeEventListener('message', catchIframeMessage)
})
} else {
const catchParentMessage = (event: MessageEvent) => {
const { data } = event
if (data.line !== proto.iframeId) {
return
}
this.rootId = data.id
this.session.setSessionToken(data.token as string)
this.frameOderNumber = data.frameOrderNumber
this.debug.log('starting iframe tracking', data)
this.allowAppStart()
}
window.addEventListener('message', catchParentMessage)
this.attachStopCallback(() => {
window.removeEventListener('message', catchParentMessage)
})
// communicating with parent window,
// even if its crossdomain is possible via postMessage api
const domain = this.initialHostName
window.parent.postMessage(
{
line: proto.iframeSignal,
source: thisTab,
context: this.contextId,
domain,
},
'*',
)
window.addEventListener('message', this.crossDomainIframeListener)
}
if (this.bc !== null) {
@ -488,7 +397,7 @@ export default class App {
})
this.startTimeout = setTimeout(() => {
this.allowAppStart()
}, 500)
}, 250)
this.bc.onmessage = (ev: MessageEvent<RickRoll>) => {
if (ev.data.context === this.contextId) {
return
@ -519,8 +428,204 @@ export default class App {
}
}
/** used by child iframes for crossdomain only */
/** used by child iframes for crossdomain only */
parentActive = false
checkStatus = () => {
return this.parentActive
}
parentCrossDomainFrameListener = (event: MessageEvent) => {
const { data } = event
if (!data || event.source === window) return
if (data.line === proto.startIframe) {
if (this.active()) return
try {
this.allowAppStart()
void this.start()
} catch (e) {
console.error('children frame restart failed:', e)
}
}
if (data.line === proto.parentAlive) {
this.parentActive = true
}
if (data.line === proto.iframeId) {
this.parentActive = true
this.rootId = data.id
this.session.setSessionToken(data.token as string)
this.frameOderNumber = data.frameOrderNumber
this.debug.log('starting iframe tracking', data)
this.allowAppStart()
}
if (data.line === proto.killIframe) {
if (this.active()) {
this.stop()
}
}
}
/**
* 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
if (!data) return
if (data.line === proto.iframeSignal) {
// @ts-ignore
event.source?.postMessage({ ping: true, line: proto.parentAlive }, '*')
const pageIframes = Array.from(document.querySelectorAll('iframe'))
this.pageFrames = pageIframes
const signalId = async () => {
if (event.source === null) {
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(data.context)) {
try {
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,
id,
token,
// 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()
}
/**
* proxying messages from iframe to main body, so they can be in one batch (same indexes, etc)
* plus we rewrite some of the messages to be relative to the main context/window
* */
if (data.line === proto.iframeBatch) {
const msgBatch = data.messages
const mappedMessages: Message[] = msgBatch.map((msg: Message) => {
if (msg[0] === MType.MouseMove) {
let fixedMessage = msg
this.pageFrames.forEach((frame) => {
if (frame.contentWindow === event.source) {
const [type, x, y] = msg
const { left, top } = frame.getBoundingClientRect()
fixedMessage = [type, x + left, y + top]
}
})
return fixedMessage
}
if (msg[0] === MType.MouseClick) {
let fixedMessage = msg
this.pageFrames.forEach((frame) => {
if (frame.contentWindow === event.source) {
const [type, id, hesitationTime, label, selector, normX, normY] = msg
const { left, top, width, height } = frame.getBoundingClientRect()
const contentWidth = document.documentElement.scrollWidth
const contentHeight = document.documentElement.scrollHeight
// (normalizedX * frameWidth + frameLeftOffset)/docSize
const fullX = (normX / 100) * width + left
const fullY = (normY / 100) * height + top
const fixedX = fullX / contentWidth
const fixedY = fullY / contentHeight
fixedMessage = [
type,
id,
hesitationTime,
label,
selector,
Math.round(fixedX * 1e3) / 1e1,
Math.round(fixedY * 1e3) / 1e1,
]
}
})
return fixedMessage
}
return msg
})
this.messages.push(...mappedMessages)
}
if (data.line === proto.polling) {
if (!this.pollingQueue.order.length) {
return
}
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: nextCommand }, '*')
if (this.pollingQueue[nextCommand].length === 0) {
this.pollingQueue.order.shift()
}
}
}
}
/**
* { 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.addCommand(proto.startIframe)
}
public killChildrenFrames = () => {
this.addCommand(proto.killIframe)
}
signalIframeTracker = () => {
const thisTab = this.session.getTabId()
const signalToParent = (n: number) => {
window.parent.postMessage(
{
line: proto.iframeSignal,
source: thisTab,
context: this.contextId,
},
this.options.crossdomain?.parentDomain ?? '*',
)
setTimeout(() => {
if (!this.checkStatus() && n < 100) {
void signalToParent(n + 1)
}
}, 250)
}
void signalToParent(1)
}
startTimeout: ReturnType<typeof setTimeout> | null = null
private allowAppStart() {
public allowAppStart() {
this.canStart = true
if (this.startTimeout) {
clearTimeout(this.startTimeout)
@ -528,15 +633,38 @@ export default class App {
}
}
private checkNodeId(iframes: HTMLIFrameElement[], domain: string) {
private async checkNodeId(
iframes: HTMLIFrameElement[],
source: MessageEventSource,
): Promise<number | null> {
for (const iframe of iframes) {
if (iframe.dataset.domain === domain) {
// @ts-ignore
return iframe[this.options.node_id] as number | undefined
if (iframe.contentWindow && iframe.contentWindow === source) {
/**
* Here we're trying to get node id from the iframe (which is kept in observer)
* because of async nature of dom initialization, we give 100 retries with 100ms delay each
* which equals to 10 seconds. This way we have a period where we give app some time to load
* and tracker some time to parse the initial DOM tree even on slower devices
* */
let tries = 0
while (tries < 100) {
// @ts-ignore
const potentialId = iframe[this.options.node_id]
if (potentialId !== undefined) {
tries = 100
return potentialId
} else {
tries++
await delay(100)
}
}
return null
}
}
return null
}
private initWorker() {
try {
this.worker = new Worker(
@ -647,28 +775,28 @@ export default class App {
this.messages.length = 0
return
}
if (this.worker === undefined || !this.messages.length) {
return
}
if (this.insideIframe) {
window.parent.postMessage(
{
line: proto.iframeBatch,
messages: this.messages,
domain: this.initialHostName,
},
'*',
this.options.crossdomain?.parentDomain ?? '*',
)
this.commitCallbacks.forEach((cb) => cb(this.messages))
this.messages.length = 0
return
}
if (this.worker === undefined || !this.messages.length) {
return
}
try {
requestIdleCb(() => {
this.messages.unshift(TabData(this.session.getTabId()))
this.messages.unshift(Timestamp(this.timestamp()))
// why I need to add opt chaining?
this.worker?.postMessage(this.messages)
this.commitCallbacks.forEach((cb) => cb(this.messages))
this.messages.length = 0
@ -740,36 +868,39 @@ export default class App {
this.commitCallbacks.push(cb)
}
attachStartCallback(cb: StartCallback, useSafe = false): void {
attachStartCallback = (cb: StartCallback, useSafe = false): void => {
if (useSafe) {
cb = this.safe(cb)
}
this.startCallbacks.push(cb)
}
attachStopCallback(cb: () => any, useSafe = false): void {
attachStopCallback = (cb: () => any, useSafe = false): void => {
if (useSafe) {
cb = this.safe(cb)
}
this.stopCallbacks.push(cb)
}
// Use app.nodes.attachNodeListener for registered nodes instead
attachEventListener(
attachEventListener = (
target: EventTarget,
type: string,
listener: EventListener,
useSafe = true,
useCapture = true,
): void {
): void => {
if (useSafe) {
listener = this.safe(listener)
}
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)
@ -1157,7 +1288,7 @@ export default class App {
if (isColdStart && this.coldInterval) {
clearInterval(this.coldInterval)
}
if (!this.worker) {
if (!this.worker && !this.insideIframe) {
const reason = 'No worker found: perhaps, CSP is not set.'
this.signalError(reason, [])
return Promise.resolve(UnsuccessfulStart(reason))
@ -1189,7 +1320,7 @@ export default class App {
})
const timestamp = now()
this.worker.postMessage({
this.worker?.postMessage({
type: 'start',
pageNo: this.session.incPageNo(),
ingestPoint: this.options.ingestPoint,
@ -1237,7 +1368,7 @@ export default class App {
const reason = error === CANCELED ? CANCELED : `Server error: ${r.status}. ${error}`
return UnsuccessfulStart(reason)
}
if (!this.worker) {
if (!this.worker && !this.insideIframe) {
const reason = 'no worker found after start request (this should not happen in real world)'
this.signalError(reason, [])
return UnsuccessfulStart(reason)
@ -1295,9 +1426,9 @@ export default class App {
if (socketOnly) {
this.socketMode = true
this.worker.postMessage('stop')
this.worker?.postMessage('stop')
} else {
this.worker.postMessage({
this.worker?.postMessage({
type: 'auth',
token,
beaconSizeLimit,
@ -1320,11 +1451,17 @@ export default class App {
// TODO: start as early as possible (before receiving the token)
/** after start */
this.startCallbacks.forEach((cb) => cb(onStartInfo)) // MBTODO: callbacks after DOM "mounted" (observed)
if (startOpts.startCallback) {
startOpts.startCallback(SuccessfulStart(onStartInfo))
}
if (this.features['feature-flags']) {
void this.featureFlags.reloadFlags()
}
await this.tagWatcher.fetchTags(this.options.ingestPoint, token)
this.activityState = ActivityState.Active
if (this.options.crossdomain?.enabled && !this.insideIframe) {
void this.bootChildrenFrames()
}
if (canvasEnabled && !this.options.canvas.disableCanvas) {
this.canvasRecorder =
@ -1336,7 +1473,6 @@ export default class App {
fixedScaling: this.options.canvas.fixedCanvasScaling,
useAnimationFrame: this.options.canvas.useAnimationFrame,
})
this.canvasRecorder.startTracking()
}
/** --------------- COLD START BUFFER ------------------*/
@ -1359,9 +1495,12 @@ export default class App {
}
this.ticker.start()
}
this.canvasRecorder?.startTracking()
if (this.features['usability-test']) {
this.uxtManager = this.uxtManager ? this.uxtManager : new UserTestManager(this, uxtStorageKey)
this.uxtManager = this.uxtManager
? this.uxtManager
: new UserTestManager(this, uxtStorageKey)
let uxtId: number | undefined
const savedUxtTag = this.localStorage.getItem(uxtStorageKey)
if (savedUxtTag) {
@ -1394,6 +1533,11 @@ export default class App {
} catch (reason) {
this.stop()
this.session.reset()
if (!reason) {
console.error('Unknown error during start')
this.signalError('Unknown error', [])
return UnsuccessfulStart('Unknown error')
}
if (reason === CANCELED) {
this.signalError(CANCELED, [])
return UnsuccessfulStart(CANCELED)
@ -1452,9 +1596,13 @@ export default class App {
}
async waitStarted() {
return this.waitStatus(ActivityState.Active)
}
async waitStatus(status: ActivityState) {
return new Promise((resolve) => {
const check = () => {
if (this.activityState === ActivityState.Active) {
if (this.activityState === status) {
resolve(true)
} else {
setTimeout(check, 25)
@ -1478,6 +1626,10 @@ export default class App {
return Promise.resolve(UnsuccessfulStart(reason))
}
if (this.insideIframe) {
this.signalIframeTracker()
}
if (!document.hidden) {
await this.waitStart()
return this._start(...args)
@ -1533,20 +1685,28 @@ export default class App {
stop(stopWorker = true): void {
if (this.activityState !== ActivityState.NotActive) {
try {
if (!this.insideIframe && this.options.crossdomain?.enabled) {
this.killChildrenFrames()
}
this.attributeSender.clear()
this.sanitizer.clear()
this.observer.disconnect()
this.nodes.clear()
this.ticker.stop()
this.stopCallbacks.forEach((cb) => cb())
this.debug.log('OpenReplay tracking stopped.')
this.tagWatcher.clear()
if (this.worker && stopWorker) {
this.worker.postMessage('stop')
}
this.canvasRecorder?.clear()
this.messages.length = 0
this.trackedFrames = []
this.parentActive = false
this.canStart = false
this.pollingQueue = { order: [] }
} finally {
this.activityState = ActivityState.NotActive
this.debug.log('OpenReplay tracking stopped.')
}
}
}

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,
@ -25,7 +28,7 @@ export default class Nodes {
}
// Attached once per Tracker instance
attachNodeCallback(nodeCallback: NodeCallback): void {
attachNodeCallback = (nodeCallback: NodeCallback): void => {
this.nodeCallbacks.push(nodeCallback)
}
@ -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,
@ -105,6 +105,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()))
@ -119,6 +122,7 @@ export default abstract class Observer {
}
this.commitNodes()
}) as MutationCallback,
this.app.options.angularMode,
)
}
private clear(): void {
@ -129,10 +133,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 +195,7 @@ export default abstract class Observer {
name === 'integrity' ||
name === 'crossorigin' ||
name === 'autocomplete' ||
name.substr(0, 2) === 'on'
name.substring(0, 2) === 'on'
) {
return
}

View file

@ -140,7 +140,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 +152,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,15 +146,23 @@ 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)
} catch (e) {
const msg = e.message
console.debug(
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,
)
}
}
@ -160,17 +172,23 @@ 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)
} catch (e) {
const msg = e.message
console.debug(
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,
)
}
}

View file

@ -7,7 +7,7 @@ describe('Nodes', () => {
const mockCallback = jest.fn()
beforeEach(() => {
nodes = new Nodes(nodeId)
nodes = new Nodes(nodeId, false)
mockCallback.mockClear()
})