Compare commits

...
Sign in to create a new pull request.

30 commits

Author SHA1 Message Date
nick-delirium
6bbc8a43f4
tracker: release assist v 2024-11-15 10:44:17 +01:00
nick-delirium
30659bc39f
tracker: bump v 2024-11-15 10:10:01 +01:00
nick-delirium
c051a414e0
tracker: skip check for tracker contexts array in restart 2024-11-14 16:36:48 +01:00
nick-delirium
9dbbe60787
tracker: fix changelog 2024-11-12 10:40:58 +01:00
nick-delirium
f978f868c8
tracker: change allowstart to keep true 2024-11-12 09:50:26 +01:00
nick-delirium
80be8c28da
tracker: remove seprate build for common messages 2024-10-31 18:15:22 +01:00
nick-delirium
f81395012a
tracker: carry old startopts to ensure consistent restart behavior 2024-10-31 17:48:15 +01:00
nick-delirium
5d06c98e35
better iframe restarting via assist 2024-10-28 16:25:38 +01:00
nick-delirium
3aae1aafc1
some debugging 2024-10-28 13:54:15 +01:00
nick-delirium
54e2fa6626
tracker: better restart logging 2024-10-25 17:03:41 +02:00
nick-delirium
1f2ff3a487
tracker: better restart logging 2024-10-25 15:54:38 +02:00
nick-delirium
43515a418d
instal state 2024-10-21 16:58:04 +02:00
nick-delirium
b205dbed70
tracker: fix maintainer doc node stability and build process 2024-10-21 15:59:45 +02:00
nick-delirium
f041859a06
tracker 14.0.10, fixes memory leaks, iframe tracking stability, etc 2024-10-18 12:58:06 +02:00
nick-delirium
b5e681ff00
change map/set to weakmap/set where possible, check canvas observers on time intervals and destroy peers; run node list maintainer every 30 sec (50ms ticks) 2024-10-17 15:30:17 +02:00
nick-delirium
5009491f63
tracker: better crossdomain check; angularMode -> forceNgOff toggle 2024-10-15 17:12:17 +02:00
nick-delirium
d39e9b8816
potential performance fixes for 14.x.x iframe tracking 2024-10-15 12:09:22 +02:00
nick-delirium
fa44300281
refactor options initialization to be more verbose 2024-10-14 13:31:24 +02:00
nick-delirium
40a22efd56
issue with eventlistener fixed 2024-10-14 09:52:02 +02:00
nick-delirium
0ddc9fcf7d
fix error with tests 2024-10-11 11:23:02 +02:00
nick-delirium
1cecf6f926
version bumps 2024-10-10 14:43:55 +02:00
Delirium
72fe59bcbf
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
2024-10-10 13:09:40 +02:00
nick-delirium
ba5b2f9b82
fix onstart return type 2024-10-08 14:25:47 +02:00
nick-delirium
6894da56da
on start method for start opts 2024-10-08 13:56:49 +02:00
nick-delirium
c76aed39f7
fix domain name -> event.source 2024-10-07 16:41:01 +02:00
nick-delirium
39df0c6761
prevent iframe from starting wworker 2024-10-07 15:20:43 +02:00
nick-delirium
4608cd2b63
don't start worker in iframe ctx 2024-10-07 14:42:52 +02:00
nick-delirium
6210a94258
tracker fix crossdomain tracking issues (timestamps, duping, restarts); 14.0.10 beta 2024-10-04 17:22:28 +02:00
nick-delirium
4bf8bf0ffb
fixes for bad start, iframes; 14.0.9 2024-10-02 15:50:36 +02:00
nick-delirium
74e1905cd2
fixing iframe issues... 2024-10-02 09:47:36 +02:00
53 changed files with 1781 additions and 474 deletions

View file

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

View file

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

Binary file not shown.

View file

@ -0,0 +1 @@
nodeLinker: node-modules

View file

@ -1,3 +1,12 @@
## 10.0.1
- some fixes for waitstatus usage
## 10.0.0
- memory handling improvements to prevent possible leaks on sessions with multiple canvas nodes
- use new tracker.waitStatus api to wait for restarts
## 9.0.0 ## 9.0.0
- support for message compression inside plugin (requires v1.18 frontend) - support for message compression inside plugin (requires v1.18 frontend)

View file

@ -1,7 +1,7 @@
{ {
"name": "@openreplay/tracker-assist", "name": "@openreplay/tracker-assist",
"description": "Tracker plugin for screen assistance through the WebRTC", "description": "Tracker plugin for screen assistance through the WebRTC",
"version": "9.0.1", "version": "10.0.1",
"keywords": [ "keywords": [
"WebRTC", "WebRTC",
"assistance", "assistance",
@ -16,11 +16,11 @@
"tsrun": "tsc", "tsrun": "tsc",
"lint": "eslint src --ext .ts,.js --fix --quiet", "lint": "eslint src --ext .ts,.js --fix --quiet",
"build": "bun run replace-pkg-version && bun run build-es && bun run build-cjs", "build": "bun run replace-pkg-version && bun run build-es && bun run build-cjs",
"build-es": "rm -Rf lib && tsc && bun run replace-req-version", "build-es": "rm -Rf lib && tsc --project tsconfig.json && bun run replace-req-version",
"build-cjs": "rm -Rf cjs && tsc --project tsconfig-cjs.json && echo '{ \"type\": \"commonjs\" }' > cjs/package.json && bun run replace-paths && bun run replace-req-version", "build-cjs": "rm -Rf cjs && tsc --project tsconfig-cjs.json && echo '{ \"type\": \"commonjs\" }' > cjs/package.json && bun run replace-req-version",
"replace-paths": "replace-in-files cjs/* --string='@openreplay/tracker' --replacement='@openreplay/tracker/cjs' && replace-in-files cjs/* --string='/lib/' --replacement='/'", "replace-paths": "replace-in-files cjs/* --string='@openreplay/tracker' --replacement='@openreplay/tracker/cjs' && replace-in-files cjs/* --string='/lib/' --replacement='/'",
"replace-pkg-version": "sh pkgver.sh", "replace-pkg-version": "sh pkgver.sh",
"replace-req-version": "replace-in-files lib/* cjs/* --string='REQUIRED_TRACKER_VERSION' --replacement='13.0.0'", "replace-req-version": "replace-in-files lib/* cjs/* --string='REQUIRED_TRACKER_VERSION' --replacement='14.0.10'",
"prepublishOnly": "bun run test && bun run build", "prepublishOnly": "bun run test && bun run build",
"lint-front": "lint-staged", "lint-front": "lint-staged",
"test": "jest --coverage=false", "test": "jest --coverage=false",
@ -34,7 +34,7 @@
"socket.io-client": "^4.7.2" "socket.io-client": "^4.7.2"
}, },
"peerDependencies": { "peerDependencies": {
"@openreplay/tracker": "^14.0.0 || ^13.0.0" "@openreplay/tracker": "^14.0.14"
}, },
"devDependencies": { "devDependencies": {
"@openreplay/tracker": "file:../tracker", "@openreplay/tracker": "file:../tracker",
@ -49,11 +49,12 @@
"prettier": "^2.7.1", "prettier": "^2.7.1",
"replace-in-files-cli": "^1.0.0", "replace-in-files-cli": "^1.0.0",
"ts-jest": "^29.0.3", "ts-jest": "^29.0.3",
"typescript": "^4.6.0-dev.20211126" "typescript": "^5.6.3"
}, },
"lint-staged": { "lint-staged": {
"*.{js,mjs,cjs,jsx,ts,tsx}": [ "*.{js,mjs,cjs,jsx,ts,tsx}": [
"eslint --fix --quiet" "eslint --fix --quiet"
] ]
} },
"packageManager": "yarn@4.5.0"
} }

View file

@ -86,6 +86,7 @@ export default class Assist {
private socket: Socket | null = null private socket: Socket | null = null
private peer: Peer | null = null private peer: Peer | null = null
private canvasPeers: Record<number, Peer | null> = {} private canvasPeers: Record<number, Peer | null> = {}
private canvasNodeCheckers: Map<number, any> = new Map()
private assistDemandedRestart = false private assistDemandedRestart = false
private callingState: CallingState = CallingState.False private callingState: CallingState = CallingState.False
private remoteControl: RemoteControl | null = null; private remoteControl: RemoteControl | null = null;
@ -354,14 +355,18 @@ export default class Assist {
this.assistDemandedRestart = true this.assistDemandedRestart = true
this.app.stop() this.app.stop()
this.app.clearBuffers() this.app.clearBuffers()
setTimeout(() => { this.app.waitStatus(0)
this.app.start().then(() => { this.assistDemandedRestart = false }) .then(() => {
.then(() => { this.app.allowAppStart()
this.remoteControl?.reconnect([id,]) setTimeout(() => {
}) this.app.start().then(() => { this.assistDemandedRestart = false })
.catch(e => app.debug.error(e)) .then(() => {
// TODO: check if it's needed; basically allowing some time for the app to finish everything before starting again this.remoteControl?.reconnect([id,])
}, 400) })
.catch(e => app.debug.error(e))
// TODO: check if it's needed; basically allowing some time for the app to finish everything before starting again
}, 100)
})
} }
}) })
socket.on('AGENTS_CONNECTED', (ids: string[]) => { socket.on('AGENTS_CONNECTED', (ids: string[]) => {
@ -375,14 +380,17 @@ export default class Assist {
if (this.app.active()) { if (this.app.active()) {
this.assistDemandedRestart = true this.assistDemandedRestart = true
this.app.stop() this.app.stop()
setTimeout(() => { this.app.waitStatus(0)
this.app.start().then(() => { this.assistDemandedRestart = false }) .then(() => {
.then(() => { this.app.allowAppStart()
this.remoteControl?.reconnect(ids) setTimeout(() => {
}) this.app.start().then(() => { this.assistDemandedRestart = false })
.catch(e => app.debug.error(e)) .then(() => {
// TODO: check if it's needed; basically allowing some time for the app to finish everything before starting again this.remoteControl?.reconnect(ids)
}, 400) })
.catch(e => app.debug.error(e))
}, 100)
})
} }
}) })
@ -670,6 +678,20 @@ export default class Assist {
app.debug.error, app.debug.error,
) )
this.canvasMap.set(id, canvasHandler) this.canvasMap.set(id, canvasHandler)
if (this.canvasNodeCheckers.has(id)) {
clearInterval(this.canvasNodeCheckers.get(id))
}
const int = setInterval(() => {
const isPresent = node.ownerDocument.defaultView && node.isConnected
if (!isPresent) {
canvasHandler.stop()
this.canvasMap.delete(id)
this.canvasPeers[id]?.destroy()
this.canvasPeers[id] = null
clearInterval(int)
}
}, 5000)
this.canvasNodeCheckers.set(id, int)
} }
}) })
} }
@ -699,6 +721,10 @@ export default class Assist {
this.socket.disconnect() this.socket.disconnect()
this.app.debug.log('Socket disconnected') this.app.debug.log('Socket disconnected')
} }
this.canvasMap.clear()
this.canvasPeers = []
this.canvasNodeCheckers.forEach((int) => clearInterval(int))
this.canvasNodeCheckers.clear()
} }
} }

View file

@ -1,7 +1,7 @@
import Mouse from './Mouse.js' import Mouse from './Mouse.js'
import ConfirmWindow from './ConfirmWindow/ConfirmWindow.js' import ConfirmWindow from './ConfirmWindow/ConfirmWindow.js'
import { controlConfirmDefault, } from './ConfirmWindow/defaults.js' import { controlConfirmDefault, } from './ConfirmWindow/defaults.js'
import type { Options as AssistOptions, } from './Assist' import type { Options as AssistOptions, } from './Assist.js'
export enum RCStatus { export enum RCStatus {
Disabled, Disabled,

View file

@ -11,6 +11,9 @@ export default function(opts?: Partial<Options>) {
if (app === null || !navigator?.mediaDevices?.getUserMedia) { if (app === null || !navigator?.mediaDevices?.getUserMedia) {
return return
} }
if (app.insideIframe) {
return
}
if (!app.checkRequiredVersion || !app.checkRequiredVersion('REQUIRED_TRACKER_VERSION')) { if (!app.checkRequiredVersion || !app.checkRequiredVersion('REQUIRED_TRACKER_VERSION')) {
console.warn('OpenReplay Assist: couldn\'t load. The minimum required version of @openreplay/tracker@REQUIRED_TRACKER_VERSION is not met') console.warn('OpenReplay Assist: couldn\'t load. The minimum required version of @openreplay/tracker@REQUIRED_TRACKER_VERSION is not met')
return return

View file

@ -1 +1 @@
export const pkgVersion = "9.0.1"; export const pkgVersion = "10.0.1";

View file

@ -4,8 +4,8 @@
"strictNullChecks": true, "strictNullChecks": true,
"alwaysStrict": true, "alwaysStrict": true,
"target": "es2017", "target": "es2017",
"module": "es6", "module": "ESNext",
"moduleResolution": "node", "moduleResolution": "Node",
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"declaration": true, "declaration": true,
"outDir": "./lib", "outDir": "./lib",

Binary file not shown.

View file

@ -0,0 +1 @@
nodeLinker: node-modules

View file

@ -1,239 +1,297 @@
# 14.0.8 ## 14.0.13
- fixes for restart logic
- fixed top context check in case of crossdomain placement
- fixed crossdomain restart logic (when triggered via assist)
- keep allowstart option on manual stop
## 14.0.11 & .12
- fix for node maintainer stability around `#document` nodes (mainly iframes field)
## 14.0.10
- adjust timestamps for messages from tracker instances inside child iframes (if they were loaded later)
- restart child trackers if parent tracker is restarted
- fixes for general stability of crossdomain iframe tracking
- refactored usage of memory for everything regarding dom nodes to prevent possible memory leaks (i.e switched Map/Set to WeakMap/WeakSet where possible)
- introduced configurable Maintainer to drop nodes that are not in the dom anymore from memory;
```
interface MaintainerOptions {
/**
* Run cleanup each X ms
*
* @default 30 * 1000
* */
interval: number
/**
* Maintainer checks nodes in small batches over 50ms timeouts
*
* @default 2500
* */
batchSize: number
/**
* @default true
* */
enabled: boolean
}
new Tracker({
...yourOptions,
nodes: {
maintainer: {
interval: 60 * 1000,
batchSize: 2500,
enabled: true
}
}
})
```
- added `startCallback` option callback to tracker.start options (returns `{ success: false, reason: string } | { success: true, sessionToken, userUUID, sessionID }`)
## 14.0.9
- more stable crossdomain iframe tracking (refactored child/parent process discovery)
- checks for bad start error
## 14.0.8
- use separate library to handle network requests ([@openreplay/network-proxy](https://www.npmjs.com/package/@openreplay/network-proxy)) - use separate library to handle network requests ([@openreplay/network-proxy](https://www.npmjs.com/package/@openreplay/network-proxy))
- fixes for window.message listeners - fixes for window.message listeners
# 14.0.7 ## 14.0.7
- check for stopping status during restarts - check for stopping status during restarts
- restart if token expired during canvas fetch - restart if token expired during canvas fetch
# 14.0.6 ## 14.0.6
- support feature off toggle for feature flags and usability testing - support feature off toggle for feature flags and usability testing
- additional checks for canvas snapshots - additional checks for canvas snapshots
# 14.0.5 ## 14.0.5
- remove canvas snapshot interval if canvas is gone - remove canvas snapshot interval if canvas is gone
# 14.0.4 ## 14.0.4
- remove reject from start - remove reject from start
# 14.0.3 ## 14.0.3
- send integer instead of float for normalizedX/Y coords (basically moving from 0-100 to 0-10000 range) - send integer instead of float for normalizedX/Y coords (basically moving from 0-100 to 0-10000 range)
# 14.0.2 ## 14.0.2
- fix logger check - fix logger check
# 14.0.0 & .1 ## 14.0.0 & .1
- titles for tabs - titles for tabs
- new `MouseClick` message to introduce heatmaps instead of clickmaps - new `MouseClick` message to introduce heatmaps instead of clickmaps
- crossdomain iframe tracking functionality - crossdomain iframe tracking functionality
- updated graphql plugin and messages - updated graphql plugin and messages
# 13.0.2 ## 13.0.2
- more file extensions for canvas - more file extensions for canvas
# 13.0.1 ## 13.0.1
- moved canvas snapshots to webp, additional option to utilize useAnimationFrame method (for webgl) - moved canvas snapshots to webp, additional option to utilize useAnimationFrame method (for webgl)
- simpler, faster canvas recording manager - simpler, faster canvas recording manager
# 13.0.0 ## 13.0.0
- `assistOnly` flag for tracker options (EE only feature) - `assistOnly` flag for tracker options (EE only feature)
# 12.0.12 ## 12.0.12
- fix for potential redux plugin issues after .11 ... - fix for potential redux plugin issues after .11 ...
# 12.0.11 ## 12.0.11
- better restart on unauth (new token assign for long sessions) - better restart on unauth (new token assign for long sessions)
- more safeguards around arraybuffer and dataview types for network proxy - more safeguards around arraybuffer and dataview types for network proxy
# 12.0.10 ## 12.0.10
- improved logs for node binding errors, full nodelist clear before start, getSessionInfo method - improved logs for node binding errors, full nodelist clear before start, getSessionInfo method
# 12.0.9 ## 12.0.9
- moved logging to query - moved logging to query
# 12.0.8 ## 12.0.8
- better logging for network batches - better logging for network batches
# 12.0.7 ## 12.0.7
- fixes for window.open reinit method - fixes for window.open reinit method
# 12.0.6 ## 12.0.6
- allow network sanitizer to return null (will ignore network req) - allow network sanitizer to return null (will ignore network req)
# 12.0.5 ## 12.0.5
- patch for img.ts srcset detector - patch for img.ts srcset detector
# 12.0.4 ## 12.0.4
- patch for email sanitizer (supports + now) - patch for email sanitizer (supports + now)
- update fflate version for better compression - update fflate version for better compression
- `disableCanvas` option to disable canvas capture - `disableCanvas` option to disable canvas capture
- better check for adopted stylesheets in doc (old browser support) - better check for adopted stylesheets in doc (old browser support)
# 12.0.3 ## 12.0.3
- fixed scaling option for canvas (to ignore window.devicePixelRatio and always render the canvas as 1) - fixed scaling option for canvas (to ignore window.devicePixelRatio and always render the canvas as 1)
# 12.0.2 ## 12.0.2
- fix for canvas snapshot check - fix for canvas snapshot check
# 12.0.1 ## 12.0.1
- pause canvas snapshotting when its offscreen - pause canvas snapshotting when its offscreen
# 12.0.0 ## 12.0.0
- offline session recording and manual sending - offline session recording and manual sending
- conditional recording with 30s buffer - conditional recording with 30s buffer
- websockets tracking hook - websockets tracking hook
# 11.0.5 ## 11.0.5
- add method to restart canvas tracking (in case of context recreation) - add method to restart canvas tracking (in case of context recreation)
- scan dom tree for canvas els on tracker start - scan dom tree for canvas els on tracker start
# 11.0.4 ## 11.0.4
- some additional security for canvas capture (check if canvas el itself is obscured/ignored) - some additional security for canvas capture (check if canvas el itself is obscured/ignored)
# 11.0.3 ## 11.0.3
- move all logs under internal debugger - move all logs under internal debugger
- fix for XHR proxy ORSC 'abort' state - fix for XHR proxy ORSC 'abort' state
# 11.0.1 & 11.0.2 ## 11.0.1 & 11.0.2
- minor fixes and refactoring - minor fixes and refactoring
# 11.0.0 ## 11.0.0
- canvas support - canvas support
- some safety guards for iframe components - some safety guards for iframe components
- user testing module - user testing module
# 10.0.2 ## 10.0.2
- fix default ignore headers - fix default ignore headers
# 10.0.1 ## 10.0.1
- network proxy api is now default turned on - network proxy api is now default turned on
# 10.0.0 ## 10.0.0
- networkRequest message changed to include `TransferredBodySize` - networkRequest message changed to include `TransferredBodySize`
- tracker now attempts to create proxy for beacon api as well (if its in scope of the current env) - tracker now attempts to create proxy for beacon api as well (if its in scope of the current env)
- safe wrapper for angular apps - safe wrapper for angular apps
- better browser lag handling (and some performance improvements as a bonus) - better browser lag handling (and some performance improvements as a bonus)
# 9.0.11 ## 9.0.11
- new `resetTabOnWindowOpen` option to fix window.open issue with sessionStorage being inherited (replicating tabId bug), users still should use 'noopener=true' in window.open to prevent it in general... - new `resetTabOnWindowOpen` option to fix window.open issue with sessionStorage being inherited (replicating tabId bug), users still should use 'noopener=true' in window.open to prevent it in general...
- do not create BC channel in iframe context, add regeneration of tabid incase of duplication - do not create BC channel in iframe context, add regeneration of tabid incase of duplication
# 9.0.10 ## 9.0.10
- added `excludedResourceUrls` to timings options to better sanitize network data - added `excludedResourceUrls` to timings options to better sanitize network data
# 9.0.9 ## 9.0.9
- Fix for `{disableStringDict: true}` behavior - Fix for `{disableStringDict: true}` behavior
# 9.0.8 ## 9.0.8
- added slight delay to iframe handler (rapid updates of stacked frames used to break player) - added slight delay to iframe handler (rapid updates of stacked frames used to break player)
# 9.0.7 ## 9.0.7
- fix for `getSessionURL` method - fix for `getSessionURL` method
# 9.0.6 ## 9.0.6
- added `tokenUrlMatcher` option to network settings, allowing to ingest session token header to custom allowed urls - added `tokenUrlMatcher` option to network settings, allowing to ingest session token header to custom allowed urls
# 9.0.5 ## 9.0.5
- same fixes but for fetch proxy - same fixes but for fetch proxy
# 9.0.2 & 9.0.3 & 9.0.4 ## 9.0.2 & 9.0.3 & 9.0.4
- fixes for "setSessionTokenHeader" method - fixes for "setSessionTokenHeader" method
# 9.0.1 ## 9.0.1
- Warning about SSR mode - Warning about SSR mode
- Prevent crashes due to network proxy in SSR - Prevent crashes due to network proxy in SSR
# 9.0.0 ## 9.0.0
- Option to disable string dictionary `{disableStringDict: true}` in Tracker constructor - Option to disable string dictionary `{disableStringDict: true}` in Tracker constructor
- Introduced Feature flags api - Introduced Feature flags api
- Fixed input durations recorded on programmable autofill - Fixed input durations recorded on programmable autofill
- change InputMode from enum to const Object - change InputMode from enum to const Object
# 8.1.2 ## 8.1.2
- option to disable string dictionary `{disableStringDict: true}` in Tracker constructor - option to disable string dictionary `{disableStringDict: true}` in Tracker constructor
# 8.1.1 ## 8.1.1
[collective patch] [collective patch]
- Console and network are now using proxy objects to capture calls (opt in for network), use ` { network: { useProxy: true } }` to enable it - Console and network are now using proxy objects to capture calls (opt in for network), use ` { network: { useProxy: true } }` to enable it
- Force disable Multitab feature for old browsers (2016 and older + safari 14) - Force disable Multitab feature for old browsers (2016 and older + safari 14)
# 8.0.0 ## 8.0.0
- **[breaking]** support for multi-tab sessions - **[breaking]** support for multi-tab sessions
# 7.0.4 ## 7.0.4
- option to disable string dictionary `{disableStringDict: true}` in Tracker constructor - option to disable string dictionary `{disableStringDict: true}` in Tracker constructor
# 7.0.3 ## 7.0.3
- Prevent auto restart after manual stop - Prevent auto restart after manual stop
# 7.0.2 ## 7.0.2
- fixed header sanitization for axios causing empty string in some cases - fixed header sanitization for axios causing empty string in some cases
# 7.0.1 ## 7.0.1
- fix time inputs capturing - fix time inputs capturing
- add option `{ network: { captureInIframes: boolean } }` to disable network tracking inside iframes (default true) - add option `{ network: { captureInIframes: boolean } }` to disable network tracking inside iframes (default true)
- added option `{ network: { axiosInstances: AxiosInstance[] } }` to include custom axios instances for better tracking - added option `{ network: { axiosInstances: AxiosInstance[] } }` to include custom axios instances for better tracking
# 7.0.0 ## 7.0.0
- **[breaking]** added gzip compression to large messages - **[breaking]** added gzip compression to large messages
- fix email regexp to significantly improve performance - fix email regexp to significantly improve performance
# 6.0.2 ## 6.0.2
- fix network tracking for same domain iframes created by js code - fix network tracking for same domain iframes created by js code
# 6.0.1 ## 6.0.1
- fix webworker writer re-init request - fix webworker writer re-init request
- remove useless logs - remove useless logs
@ -241,7 +299,7 @@
- fix iframe handling - fix iframe handling
- optimise node counting for dom drop - optimise node counting for dom drop
# 6.0.0 ## 6.0.0
**(Compatible with OpenReplay v1.11.0+ only)** **(Compatible with OpenReplay v1.11.0+ only)**

Binary file not shown.

View file

@ -1,7 +1,7 @@
{ {
"name": "@openreplay/tracker", "name": "@openreplay/tracker",
"description": "The OpenReplay tracker main package", "description": "The OpenReplay tracker main package",
"version": "14.0.8", "version": "14.0.14",
"keywords": [ "keywords": [
"logging", "logging",
"replay" "replay"
@ -13,14 +13,28 @@
], ],
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
"main": "./lib/index.js", "exports": {
".": {
"require": "./dist/cjs/index.js",
"import": "./dist/lib/index.js",
"types": "./dist/lib/main/index.d.ts"
},
"./cjs": {
"require": "./dist/cjs/index.js",
"types": "./dist/cjs/main/index.d.ts"
}
},
"files": [
"dist/lib/**/*",
"dist/cjs/**/*"
],
"main": "./dist/cjs/index.js",
"module": "./dist/lib/index.js",
"types": "./dist/lib/main/index.d.ts",
"scripts": { "scripts": {
"lint": "eslint src --ext .ts,.js --fix --quiet", "lint": "eslint src --ext .ts,.js --fix --quiet",
"clean": "rm -Rf build && rm -Rf lib && rm -Rf cjs", "clean": "rm -Rf build && rm -Rf lib && rm -Rf cjs",
"tscRun": "tsc -b src/main && tsc -b src/webworker && tsc --project src/main/tsconfig-cjs.json", "build": "yarn run clean && rollup --config rollup.config.js",
"rollup": "rollup --config rollup.config.js",
"compile": "node --experimental-modules --experimental-json-modules scripts/compile.cjs",
"build": "bun run clean && bun run tscRun && bun run rollup && bun run compile",
"lint-front": "lint-staged", "lint-front": "lint-staged",
"test": "jest --coverage=false", "test": "jest --coverage=false",
"test:ci": "jest --coverage=true", "test:ci": "jest --coverage=true",
@ -32,37 +46,33 @@
"@jest/globals": "^29.3.1", "@jest/globals": "^29.3.1",
"@rollup/plugin-babel": "^6.0.4", "@rollup/plugin-babel": "^6.0.4",
"@rollup/plugin-node-resolve": "^15.2.3", "@rollup/plugin-node-resolve": "^15.2.3",
"@typescript-eslint/eslint-plugin": "^5.30.0", "@rollup/plugin-replace": "^6.0.1",
"@typescript-eslint/parser": "^5.30.0", "@rollup/plugin-terser": "0.4.4",
"eslint": "^7.8.0", "@rollup/plugin-typescript": "^12.1.1",
"@typescript-eslint/eslint-plugin": "^8.10.0",
"@typescript-eslint/parser": "^8.10.0",
"eslint": "^8.57.1",
"eslint-config-prettier": "^9.0.0", "eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.1", "eslint-plugin-prettier": "^5.0.1",
"jest": "^29.3.1", "jest": "^29.3.1",
"jest-environment-jsdom": "^29.3.1", "jest-environment-jsdom": "^29.3.1",
"lint-staged": "^13.0.3",
"prettier": "^3.0.3", "prettier": "^3.0.3",
"replace-in-files": "^2.0.3", "replace-in-files": "^2.0.3",
"rollup": "^4.1.4", "rollup": "^4.1.4",
"rollup-plugin-terser": "^7.0.2",
"semver": "^6.3.0", "semver": "^6.3.0",
"ts-jest": "^29.0.3", "ts-jest": "^29.0.3",
"typescript": "^4.9.4" "tslib": "^2.8.0",
"typescript": "^5.6.3"
}, },
"dependencies": { "dependencies": {
"@medv/finder": "^3.2.0", "@medv/finder": "^3.2.0",
"@openreplay/network-proxy": "^1.0.3", "@openreplay/network-proxy": "^1.0.4",
"error-stack-parser": "^2.0.6", "error-stack-parser": "^2.0.6",
"error-stack-parser-es": "^0.1.5",
"fflate": "^0.8.2" "fflate": "^0.8.2"
}, },
"engines": { "engines": {
"node": ">=14.0" "node": ">=14.0"
}, },
"lint-staged": { "packageManager": "yarn@4.5.1"
"*.{js,mjs,jsx,ts,tsx}": [
"eslint --fix --quiet"
],
"*.{json,md,html,js,jsx,ts,tsx}": [
"prettier --write"
]
}
} }

View file

@ -1,12 +1,81 @@
import resolve from '@rollup/plugin-node-resolve' import resolve from '@rollup/plugin-node-resolve'
import { babel } from '@rollup/plugin-babel' import typescript from '@rollup/plugin-typescript'
import { terser } from 'rollup-plugin-terser' import terser from '@rollup/plugin-terser'
import replace from '@rollup/plugin-replace'
import { rollup } from 'rollup'
import { createRequire } from 'module'
const require = createRequire(import.meta.url)
const packageConfig = require('./package.json')
export default { export default async () => {
input: 'build/webworker/index.js', const webworkerContent = await buildWebWorker()
output: {
file: 'build/webworker.js', const commonPlugins = [
format: 'cjs', resolve(),
}, // terser(),
plugins: [resolve(), babel({ babelHelpers: 'bundled' }), terser({ mangle: { reserved: ['$'] } })], replace({
preventAssignment: true,
values: {
TRACKER_VERSION: packageConfig.version,
'global.WEBWORKER_BODY': JSON.stringify(webworkerContent),
},
}),
]
return [
{
input: 'src/main/index.ts',
output: {
dir: 'dist/lib',
format: 'es',
sourcemap: true,
entryFileNames: '[name].js',
},
plugins: [
...commonPlugins,
typescript({
tsconfig: 'src/main/tsconfig.json',
}),
],
},
{
input: 'src/main/index.ts',
output: {
dir: 'dist/cjs',
format: 'cjs',
sourcemap: true,
entryFileNames: '[name].js',
},
plugins: [
...commonPlugins,
typescript({
tsconfig: 'src/main/tsconfig-cjs.json',
}),
],
},
]
}
async function buildWebWorker() {
console.log('building wworker')
const bundle = await rollup({
input: 'src/webworker/index.ts',
plugins: [
resolve(),
typescript({
tsconfig: 'src/webworker/tsconfig.json',
}),
terser(),
],
})
const { output } = await bundle.generate({
format: 'iife',
name: 'WebWorker',
inlineDynamicImports: true,
})
const webWorkerCode = output[0].code
console.log('webworker done!', output.length)
return webWorkerCode
} }

View file

@ -0,0 +1,37 @@
import Message from './messages.gen.js';
export interface Options {
connAttemptCount?: number;
connAttemptGap?: number;
}
type Start = {
type: 'start';
ingestPoint: string;
pageNo: number;
timestamp: number;
url: string;
tabId: string;
} & Options;
type Auth = {
type: 'auth';
token: string;
beaconSizeLimit?: number;
};
export type ToWorkerData = null | 'stop' | Start | Auth | Array<Message> | {
type: 'compressed';
batch: Uint8Array;
} | {
type: 'uncompressed';
batch: Uint8Array;
} | 'forceFlushBatch' | 'check_queue';
type Failure = {
type: 'failure';
reason: string;
};
type QEmpty = {
type: 'queue_empty';
};
export type FromWorkerData = 'a_stop' | 'a_start' | Failure | 'not_init' | {
type: 'compress';
batch: Uint8Array;
} | QEmpty;
export {};

View file

@ -0,0 +1 @@
export {};

View file

@ -0,0 +1 @@
{"version":3,"file":"interaction.js","sourceRoot":"","sources":["interaction.ts"],"names":[],"mappings":""}

View file

@ -0,0 +1,548 @@
export declare const enum Type {
Timestamp = 0,
SetPageLocationDeprecated = 4,
SetViewportSize = 5,
SetViewportScroll = 6,
CreateDocument = 7,
CreateElementNode = 8,
CreateTextNode = 9,
MoveNode = 10,
RemoveNode = 11,
SetNodeAttribute = 12,
RemoveNodeAttribute = 13,
SetNodeData = 14,
SetNodeScroll = 16,
SetInputTarget = 17,
SetInputValue = 18,
SetInputChecked = 19,
MouseMove = 20,
NetworkRequestDeprecated = 21,
ConsoleLog = 22,
PageLoadTiming = 23,
PageRenderTiming = 24,
CustomEvent = 27,
UserID = 28,
UserAnonymousID = 29,
Metadata = 30,
CSSInsertRule = 37,
CSSDeleteRule = 38,
Fetch = 39,
Profiler = 40,
OTable = 41,
StateAction = 42,
ReduxDeprecated = 44,
Vuex = 45,
MobX = 46,
NgRx = 47,
GraphQLDeprecated = 48,
PerformanceTrack = 49,
StringDict = 50,
SetNodeAttributeDict = 51,
ResourceTimingDeprecated = 53,
ConnectionInformation = 54,
SetPageVisibility = 55,
LoadFontFace = 57,
SetNodeFocus = 58,
LongTask = 59,
SetNodeAttributeURLBased = 60,
SetCSSDataURLBased = 61,
TechnicalInfo = 63,
CustomIssue = 64,
CSSInsertRuleURLBased = 67,
MouseClick = 68,
MouseClickDeprecated = 69,
CreateIFrameDocument = 70,
AdoptedSSReplaceURLBased = 71,
AdoptedSSInsertRuleURLBased = 73,
AdoptedSSDeleteRule = 75,
AdoptedSSAddOwner = 76,
AdoptedSSRemoveOwner = 77,
JSException = 78,
Zustand = 79,
BatchMetadata = 81,
PartitionedMessage = 82,
NetworkRequest = 83,
WSChannel = 84,
InputChange = 112,
SelectionChange = 113,
MouseThrashing = 114,
UnbindNodes = 115,
ResourceTiming = 116,
TabChange = 117,
TabData = 118,
CanvasNode = 119,
TagTrigger = 120,
Redux = 121,
SetPageLocation = 122,
GraphQL = 123
}
export type Timestamp = [
Type.Timestamp,
number
];
export type SetPageLocationDeprecated = [
Type.SetPageLocationDeprecated,
string,
string,
number
];
export type SetViewportSize = [
Type.SetViewportSize,
number,
number
];
export type SetViewportScroll = [
Type.SetViewportScroll,
number,
number
];
export type CreateDocument = [
Type.CreateDocument
];
export type CreateElementNode = [
Type.CreateElementNode,
number,
number,
number,
string,
boolean
];
export type CreateTextNode = [
Type.CreateTextNode,
number,
number,
number
];
export type MoveNode = [
Type.MoveNode,
number,
number,
number
];
export type RemoveNode = [
Type.RemoveNode,
number
];
export type SetNodeAttribute = [
Type.SetNodeAttribute,
number,
string,
string
];
export type RemoveNodeAttribute = [
Type.RemoveNodeAttribute,
number,
string
];
export type SetNodeData = [
Type.SetNodeData,
number,
string
];
export type SetNodeScroll = [
Type.SetNodeScroll,
number,
number,
number
];
export type SetInputTarget = [
Type.SetInputTarget,
number,
string
];
export type SetInputValue = [
Type.SetInputValue,
number,
string,
number
];
export type SetInputChecked = [
Type.SetInputChecked,
number,
boolean
];
export type MouseMove = [
Type.MouseMove,
number,
number
];
export type NetworkRequestDeprecated = [
Type.NetworkRequestDeprecated,
string,
string,
string,
string,
string,
number,
number,
number
];
export type ConsoleLog = [
Type.ConsoleLog,
string,
string
];
export type PageLoadTiming = [
Type.PageLoadTiming,
number,
number,
number,
number,
number,
number,
number,
number,
number
];
export type PageRenderTiming = [
Type.PageRenderTiming,
number,
number,
number
];
export type CustomEvent = [
Type.CustomEvent,
string,
string
];
export type UserID = [
Type.UserID,
string
];
export type UserAnonymousID = [
Type.UserAnonymousID,
string
];
export type Metadata = [
Type.Metadata,
string,
string
];
export type CSSInsertRule = [
Type.CSSInsertRule,
number,
string,
number
];
export type CSSDeleteRule = [
Type.CSSDeleteRule,
number,
number
];
export type Fetch = [
Type.Fetch,
string,
string,
string,
string,
number,
number,
number
];
export type Profiler = [
Type.Profiler,
string,
number,
string,
string
];
export type OTable = [
Type.OTable,
string,
string
];
export type StateAction = [
Type.StateAction,
string
];
export type ReduxDeprecated = [
Type.ReduxDeprecated,
string,
string,
number
];
export type Vuex = [
Type.Vuex,
string,
string
];
export type MobX = [
Type.MobX,
string,
string
];
export type NgRx = [
Type.NgRx,
string,
string,
number
];
export type GraphQLDeprecated = [
Type.GraphQLDeprecated,
string,
string,
string,
string,
number
];
export type PerformanceTrack = [
Type.PerformanceTrack,
number,
number,
number,
number
];
export type StringDict = [
Type.StringDict,
number,
string
];
export type SetNodeAttributeDict = [
Type.SetNodeAttributeDict,
number,
number,
number
];
export type ResourceTimingDeprecated = [
Type.ResourceTimingDeprecated,
number,
number,
number,
number,
number,
number,
string,
string
];
export type ConnectionInformation = [
Type.ConnectionInformation,
number,
string
];
export type SetPageVisibility = [
Type.SetPageVisibility,
boolean
];
export type LoadFontFace = [
Type.LoadFontFace,
number,
string,
string,
string
];
export type SetNodeFocus = [
Type.SetNodeFocus,
number
];
export type LongTask = [
Type.LongTask,
number,
number,
number,
number,
string,
string,
string
];
export type SetNodeAttributeURLBased = [
Type.SetNodeAttributeURLBased,
number,
string,
string,
string
];
export type SetCSSDataURLBased = [
Type.SetCSSDataURLBased,
number,
string,
string
];
export type TechnicalInfo = [
Type.TechnicalInfo,
string,
string
];
export type CustomIssue = [
Type.CustomIssue,
string,
string
];
export type CSSInsertRuleURLBased = [
Type.CSSInsertRuleURLBased,
number,
string,
number,
string
];
export type MouseClick = [
Type.MouseClick,
number,
number,
string,
string,
number,
number
];
export type MouseClickDeprecated = [
Type.MouseClickDeprecated,
number,
number,
string,
string
];
export type CreateIFrameDocument = [
Type.CreateIFrameDocument,
number,
number
];
export type AdoptedSSReplaceURLBased = [
Type.AdoptedSSReplaceURLBased,
number,
string,
string
];
export type AdoptedSSInsertRuleURLBased = [
Type.AdoptedSSInsertRuleURLBased,
number,
string,
number,
string
];
export type AdoptedSSDeleteRule = [
Type.AdoptedSSDeleteRule,
number,
number
];
export type AdoptedSSAddOwner = [
Type.AdoptedSSAddOwner,
number,
number
];
export type AdoptedSSRemoveOwner = [
Type.AdoptedSSRemoveOwner,
number,
number
];
export type JSException = [
Type.JSException,
string,
string,
string,
string
];
export type Zustand = [
Type.Zustand,
string,
string
];
export type BatchMetadata = [
Type.BatchMetadata,
number,
number,
number,
number,
string
];
export type PartitionedMessage = [
Type.PartitionedMessage,
number,
number
];
export type NetworkRequest = [
Type.NetworkRequest,
string,
string,
string,
string,
string,
number,
number,
number,
number
];
export type WSChannel = [
Type.WSChannel,
string,
string,
string,
number,
string,
string
];
export type InputChange = [
Type.InputChange,
number,
string,
boolean,
string,
number,
number
];
export type SelectionChange = [
Type.SelectionChange,
number,
number,
string
];
export type MouseThrashing = [
Type.MouseThrashing,
number
];
export type UnbindNodes = [
Type.UnbindNodes,
number
];
export type ResourceTiming = [
Type.ResourceTiming,
number,
number,
number,
number,
number,
number,
string,
string,
number,
boolean
];
export type TabChange = [
Type.TabChange,
string
];
export type TabData = [
Type.TabData,
string
];
export type CanvasNode = [
Type.CanvasNode,
string,
number
];
export type TagTrigger = [
Type.TagTrigger,
number
];
export type Redux = [
Type.Redux,
string,
string,
number,
number
];
export type SetPageLocation = [
Type.SetPageLocation,
string,
string,
number,
string
];
export type GraphQL = [
Type.GraphQL,
string,
string,
string,
string,
number
];
type Message = Timestamp | SetPageLocationDeprecated | SetViewportSize | SetViewportScroll | CreateDocument | CreateElementNode | CreateTextNode | MoveNode | RemoveNode | SetNodeAttribute | RemoveNodeAttribute | SetNodeData | SetNodeScroll | SetInputTarget | SetInputValue | SetInputChecked | MouseMove | NetworkRequestDeprecated | ConsoleLog | PageLoadTiming | PageRenderTiming | CustomEvent | UserID | UserAnonymousID | Metadata | CSSInsertRule | CSSDeleteRule | Fetch | Profiler | OTable | StateAction | ReduxDeprecated | Vuex | MobX | NgRx | GraphQLDeprecated | PerformanceTrack | StringDict | SetNodeAttributeDict | ResourceTimingDeprecated | ConnectionInformation | SetPageVisibility | LoadFontFace | SetNodeFocus | LongTask | SetNodeAttributeURLBased | SetCSSDataURLBased | TechnicalInfo | CustomIssue | CSSInsertRuleURLBased | MouseClick | MouseClickDeprecated | CreateIFrameDocument | AdoptedSSReplaceURLBased | AdoptedSSInsertRuleURLBased | AdoptedSSDeleteRule | AdoptedSSAddOwner | AdoptedSSRemoveOwner | JSException | Zustand | BatchMetadata | PartitionedMessage | NetworkRequest | WSChannel | InputChange | SelectionChange | MouseThrashing | UnbindNodes | ResourceTiming | TabChange | TabData | CanvasNode | TagTrigger | Redux | SetPageLocation | GraphQL;
export default Message;

View file

@ -0,0 +1,3 @@
// Auto-generated, do not edit
/* eslint-disable */
export {};

View file

@ -0,0 +1 @@
{"version":3,"file":"messages.gen.js","sourceRoot":"","sources":["messages.gen.ts"],"names":[],"mappings":"AAAA,8BAA8B;AAC9B,oBAAoB"}

View file

@ -1,7 +0,0 @@
{
"extends": "../../tsconfig-base.json",
"compilerOptions": {
"composite": true,
"lib": ["es6"],
}
}

File diff suppressed because one or more lines are too long

View file

@ -20,7 +20,7 @@ interface Options {
class CanvasRecorder { class CanvasRecorder {
private snapshots: Record<number, CanvasSnapshot> = {} private snapshots: Record<number, CanvasSnapshot> = {}
private readonly intervals: NodeJS.Timeout[] = [] private readonly intervals: ReturnType<typeof setInterval>[] = []
private readonly interval: number private readonly interval: number
private readonly fileExt: 'webp' | 'png' | 'jpeg' | 'avif' private readonly fileExt: 'webp' | 'png' | 'jpeg' | 'avif'
@ -35,10 +35,8 @@ class CanvasRecorder {
startTracking() { startTracking() {
setTimeout(() => { setTimeout(() => {
this.app.nodes.scanTree(this.captureCanvas) this.app.nodes.scanTree(this.captureCanvas)
this.app.nodes.attachNodeCallback((node: Node): void => { this.app.nodes.attachNodeCallback(this.captureCanvas)
this.captureCanvas(node) }, 250)
})
}, 500)
} }
restartTracking = () => { restartTracking = () => {

View file

@ -32,7 +32,7 @@ import Message, {
UserID, UserID,
WSChannel, WSChannel,
} from './messages.gen.js' } from './messages.gen.js'
import Nodes from './nodes.js' import Nodes from './nodes/index.js'
import type { Options as ObserverOptions } from './observer/top_observer.js' import type { Options as ObserverOptions } from './observer/top_observer.js'
import Observer from './observer/top_observer.js' import Observer from './observer/top_observer.js'
import type { Options as SanitizerOptions } from './sanitizer.js' import type { Options as SanitizerOptions } from './sanitizer.js'
@ -40,6 +40,7 @@ import Sanitizer from './sanitizer.js'
import type { Options as SessOptions } from './session.js' import type { Options as SessOptions } from './session.js'
import Session from './session.js' import Session from './session.js'
import Ticker from './ticker.js' import Ticker from './ticker.js'
import { MaintainerOptions } from './nodes/maintainer.js'
interface TypedWorker extends Omit<Worker, 'postMessage'> { interface TypedWorker extends Omit<Worker, 'postMessage'> {
postMessage(data: ToWorkerData): void postMessage(data: ToWorkerData): void
@ -52,6 +53,12 @@ export interface StartOptions {
forceNew?: boolean forceNew?: boolean
sessionHash?: string sessionHash?: string
assistOnly?: boolean 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 { interface OnStartInfo {
@ -60,6 +67,11 @@ interface OnStartInfo {
userUUID: string userUUID: string
} }
/**
* this value is injected during build time via rollup
* */
// @ts-ignore
const workerBodyFn = global.WEBWORKER_BODY
const CANCELED = 'canceled' as const const CANCELED = 'canceled' as const
const uxtStorageKey = 'or_uxt_active' const uxtStorageKey = 'or_uxt_active'
const bufferStorageKey = 'or_buffer_1' const bufferStorageKey = 'or_buffer_1'
@ -161,6 +173,23 @@ type AppOptions = {
} }
network?: NetworkOptions network?: NetworkOptions
/**
* use this flag to force angular detection to be offline
*
* basically goes around window.Zone api changes to mutation observer
* and event listeners
* */
forceNgOff?: boolean
/**
* This option is used to change how tracker handles potentially detached nodes
*
* defaults here are tested and proven to be lightweight and easy on cpu
*
* consult the docs before changing it
* */
nodes?: {
maintainer: Partial<MaintainerOptions>
}
} & WebworkerOptions & } & WebworkerOptions &
SessOptions SessOptions
@ -185,12 +214,14 @@ const proto = {
resp: 'never-gonna-let-you-down', resp: 'never-gonna-let-you-down',
// regenerating id (copied other tab) // regenerating id (copied other tab)
reg: 'never-gonna-run-around-and-desert-you', reg: 'never-gonna-run-around-and-desert-you',
// tracker inside a child iframe iframeSignal: 'tracker inside a child iframe',
iframeSignal: 'never-gonna-make-you-cry', iframeId: 'getting node id for child iframe',
// getting node id for child iframe iframeBatch: 'batch of messages from an iframe window',
iframeId: 'never-gonna-say-goodbye', parentAlive: 'signal that parent is live',
// batch of messages from an iframe window killIframe: 'stop tracker inside frame',
iframeBatch: 'never-gonna-tell-a-lie-and-hurt-you', startIframe: 'start tracker inside frame',
// checking updates
polling: 'hello-how-are-you-im-under-the-water-please-help-me',
} as const } as const
export default class App { export default class App {
@ -237,7 +268,6 @@ export default class App {
private rootId: number | null = null private rootId: number | null = null
private pageFrames: HTMLIFrameElement[] = [] private pageFrames: HTMLIFrameElement[] = []
private frameOderNumber = 0 private frameOderNumber = 0
private readonly initialHostName = location.hostname
private features = { private features = {
'feature-flags': true, 'feature-flags': true,
'usability-test': true, 'usability-test': true,
@ -248,7 +278,7 @@ export default class App {
sessionToken: string | undefined, sessionToken: string | undefined,
options: Partial<Options>, options: Partial<Options>,
private readonly signalError: (error: string, apis: string[]) => void, 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.contextId = Math.random().toString(36).slice(2)
this.projectKey = projectKey this.projectKey = projectKey
@ -305,6 +335,7 @@ export default class App {
__save_canvas_locally: false, __save_canvas_locally: false,
useAnimationFrame: false, useAnimationFrame: false,
}, },
forceNgOff: false,
} }
this.options = simpleMerge(defaultOptions, options) this.options = simpleMerge(defaultOptions, options)
@ -321,17 +352,26 @@ export default class App {
this.revID = this.options.revID this.revID = this.options.revID
this.localStorage = this.options.localStorage ?? window.localStorage this.localStorage = this.options.localStorage ?? window.localStorage
this.sessionStorage = this.options.sessionStorage ?? window.sessionStorage this.sessionStorage = this.options.sessionStorage ?? window.sessionStorage
this.sanitizer = new Sanitizer(this, options) this.sanitizer = new Sanitizer({ app: this, options })
this.nodes = new Nodes(this.options.node_id) this.nodes = new Nodes({
this.observer = new Observer(this, options) node_id: this.options.node_id,
forceNgOff: Boolean(options.forceNgOff),
maintainer: this.options.nodes?.maintainer,
})
this.observer = new Observer({ app: this, options })
this.ticker = new Ticker(this) this.ticker = new Ticker(this)
this.ticker.attach(() => this.commit()) this.ticker.attach(() => this.commit())
this.debug = new Logger(this.options.__debug__) this.debug = new Logger(this.options.__debug__)
this.session = new Session(this, this.options) this.session = new Session({ app: this, options: this.options })
this.attributeSender = new AttributeSender(this, Boolean(this.options.disableStringDict)) this.attributeSender = new AttributeSender({
app: this,
isDictDisabled: Boolean(this.options.disableStringDict || this.options.crossdomain?.enabled),
})
this.featureFlags = new FeatureFlags(this) this.featureFlags = new FeatureFlags(this)
this.tagWatcher = new TagWatcher(this.sessionStorage, this.debug.error, (tag) => { this.tagWatcher = new TagWatcher({
this.send(TagTrigger(tag) as Message) sessionStorage: this.sessionStorage,
errLog: this.debug.error,
onTag: (tag) => this.send(TagTrigger(tag) as Message),
}) })
this.session.attachUpdateCallback(({ userID, metadata }) => { this.session.attachUpdateCallback(({ userID, metadata }) => {
if (userID != null) { if (userID != null) {
@ -348,140 +388,33 @@ export default class App {
this.session.applySessionHash(sessionToken) this.session.applySessionHash(sessionToken)
} }
this.initWorker()
const thisTab = this.session.getTabId() const thisTab = this.session.getTabId()
if (!this.insideIframe) { if (this.insideIframe) {
/**
* listen for messages from parent window, so we can signal that we're alive
* */
window.addEventListener('message', this.parentCrossDomainFrameListener)
setInterval(() => {
if (document.hidden) {
return
}
window.parent.postMessage(
{
line: proto.polling,
context: this.contextId,
},
options.crossdomain?.parentDomain ?? '*',
)
}, 250)
} else {
this.initWorker()
/** /**
* if we get a signal from child iframes, we check for their node_id and send it back, * 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 * so they can act as if it was just a same-domain iframe
* */ * */
let crossdomainFrameCount = 0 window.addEventListener('message', this.crossDomainIframeListener)
const catchIframeMessage = (event: MessageEvent) => {
const { data } = event
if (!data) return
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) return
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,
},
'*',
)
} }
if (this.bc !== null) { if (this.bc !== null) {
this.bc.postMessage({ this.bc.postMessage({
line: proto.ask, line: proto.ask,
@ -490,7 +423,7 @@ export default class App {
}) })
this.startTimeout = setTimeout(() => { this.startTimeout = setTimeout(() => {
this.allowAppStart() this.allowAppStart()
}, 500) }, 250)
this.bc.onmessage = (ev: MessageEvent<RickRoll>) => { this.bc.onmessage = (ev: MessageEvent<RickRoll>) => {
if (ev.data.context === this.contextId) { if (ev.data.context === this.contextId) {
return return
@ -521,8 +454,241 @@ export default class App {
} }
} }
/** 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 signalId = async () => {
if (event.source === null) {
return console.error('Couldnt connect to event.source for child iframe tracking')
}
const id = await this.checkNodeId(event.source)
if (!id) {
this.debug.log('Couldnt get node id for iframe', event.source)
return
}
try {
if (this.trackedFrames.includes(data.context)) {
this.debug.log('Trying to observe already added iframe; ignore if its a restart')
} else {
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)
}
}
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 (nextCommand && this.pollingQueue[nextCommand].length === 0) {
this.pollingQueue.order = this.pollingQueue.order.filter((c) => c !== nextCommand)
return
}
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()
window.parent.postMessage(
{
line: proto.iframeSignal,
source: thisTab,
context: this.contextId,
},
this.options.crossdomain?.parentDomain ?? '*',
)
/**
* since we need to wait uncertain amount of time
* and I don't want to have recursion going on,
* we'll just use a timeout loop with backoff
* */
const maxRetries = 10
let retries = 0
let delay = 250
let cumulativeDelay = 0
let stopAttempts = false
const checkAndSendMessage = () => {
if (stopAttempts || this.checkStatus()) {
stopAttempts = true
return
}
window.parent.postMessage(
{
line: proto.iframeSignal,
source: thisTab,
context: this.contextId,
},
this.options.crossdomain?.parentDomain ?? '*',
)
this.debug.info('Trying to signal to parent, attempt:', retries + 1)
retries++
}
for (let i = 0; i < maxRetries; i++) {
if (this.checkStatus()) {
stopAttempts = true
break
}
cumulativeDelay += delay
setTimeout(() => {
checkAndSendMessage()
}, cumulativeDelay)
delay *= 1.5
}
}
startTimeout: ReturnType<typeof setTimeout> | null = null startTimeout: ReturnType<typeof setTimeout> | null = null
private allowAppStart() { public allowAppStart() {
this.canStart = true this.canStart = true
if (this.startTimeout) { if (this.startTimeout) {
clearTimeout(this.startTimeout) clearTimeout(this.startTimeout)
@ -530,19 +696,46 @@ export default class App {
} }
} }
private checkNodeId(iframes: HTMLIFrameElement[], domain: string) { private async checkNodeId(source: MessageEventSource): Promise<number | null> {
for (const iframe of iframes) { let targetFrame
if (iframe.dataset.domain === domain) { if (this.pageFrames.length > 0) {
// @ts-ignore targetFrame = this.pageFrames.find((frame) => frame.contentWindow === source)
return iframe[this.options.node_id] as number | undefined }
if (!targetFrame || !this.pageFrames.length) {
const pageIframes = Array.from(document.querySelectorAll('iframe'))
this.pageFrames = pageIframes
targetFrame = pageIframes.find((frame) => frame.contentWindow === source)
}
if (!targetFrame) {
return null
}
/**
* 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 = targetFrame[this.options.node_id]
if (potentialId !== undefined) {
tries = 100
return potentialId
} else {
tries++
await delay(100)
} }
} }
return null return null
} }
private initWorker() { private initWorker() {
try { try {
this.worker = new Worker( this.worker = new Worker(
URL.createObjectURL(new Blob(['WEBWORKER_BODY'], { type: 'text/javascript' })), URL.createObjectURL(new Blob([workerBodyFn], { type: 'text/javascript' })),
) )
this.worker.onerror = (e) => { this.worker.onerror = (e) => {
this._debug('webworker_error', e) this._debug('webworker_error', e)
@ -571,7 +764,16 @@ export default class App {
if (data === 'a_stop') { if (data === 'a_stop') {
this.stop(false) this.stop(false)
} else if (data === 'a_start') { } else if (data === 'a_start') {
void this.start({}, true) this.waitStatus(ActivityState.NotActive).then(() => {
this.allowAppStart()
this.start(this.prevOpts, true)
.then((r) => {
this.debug.info('Worker restarted, session was too long', r)
})
.catch((e) => {
this.debug.error('Worker restart failed', e)
})
})
} else if (data === 'not_init') { } else if (data === 'not_init') {
this.debug.warn('OR WebWorker: writer not initialised. Restarting tracker') this.debug.warn('OR WebWorker: writer not initialised. Restarting tracker')
} else if (data.type === 'failure') { } else if (data.type === 'failure') {
@ -649,28 +851,28 @@ export default class App {
this.messages.length = 0 this.messages.length = 0
return return
} }
if (this.worker === undefined || !this.messages.length) {
return
}
if (this.insideIframe) { if (this.insideIframe) {
window.parent.postMessage( window.parent.postMessage(
{ {
line: proto.iframeBatch, line: proto.iframeBatch,
messages: this.messages, messages: this.messages,
domain: this.initialHostName,
}, },
'*', this.options.crossdomain?.parentDomain ?? '*',
) )
this.commitCallbacks.forEach((cb) => cb(this.messages)) this.commitCallbacks.forEach((cb) => cb(this.messages))
this.messages.length = 0 this.messages.length = 0
return return
} }
if (this.worker === undefined || !this.messages.length) {
return
}
try { try {
requestIdleCb(() => { requestIdleCb(() => {
this.messages.unshift(TabData(this.session.getTabId())) this.messages.unshift(TabData(this.session.getTabId()))
this.messages.unshift(Timestamp(this.timestamp())) this.messages.unshift(Timestamp(this.timestamp()))
// why I need to add opt chaining?
this.worker?.postMessage(this.messages) this.worker?.postMessage(this.messages)
this.commitCallbacks.forEach((cb) => cb(this.messages)) this.commitCallbacks.forEach((cb) => cb(this.messages))
this.messages.length = 0 this.messages.length = 0
@ -742,36 +944,39 @@ export default class App {
this.commitCallbacks.push(cb) this.commitCallbacks.push(cb)
} }
attachStartCallback(cb: StartCallback, useSafe = false): void { attachStartCallback = (cb: StartCallback, useSafe = false): void => {
if (useSafe) { if (useSafe) {
cb = this.safe(cb) cb = this.safe(cb)
} }
this.startCallbacks.push(cb) this.startCallbacks.push(cb)
} }
attachStopCallback(cb: () => any, useSafe = false): void { attachStopCallback = (cb: () => any, useSafe = false): void => {
if (useSafe) { if (useSafe) {
cb = this.safe(cb) cb = this.safe(cb)
} }
this.stopCallbacks.push(cb) this.stopCallbacks.push(cb)
} }
// Use app.nodes.attachNodeListener for registered nodes instead attachEventListener = (
attachEventListener(
target: EventTarget, target: EventTarget,
type: string, type: string,
listener: EventListener, listener: EventListener,
useSafe = true, useSafe = true,
useCapture = true, useCapture = true,
): void { ): void => {
if (useSafe) { if (useSafe) {
listener = this.safe(listener) listener = this.safe(listener)
} }
const createListener = () => const createListener = () =>
target ? createEventListener(target, type, listener, useCapture) : null target
? createEventListener(target, type, listener, useCapture, this.options.forceNgOff)
: null
const deleteListener = () => const deleteListener = () =>
target ? deleteEventListener(target, type, listener, useCapture) : null target
? deleteEventListener(target, type, listener, useCapture, this.options.forceNgOff)
: null
this.attachStartCallback(createListener, useSafe) this.attachStartCallback(createListener, useSafe)
this.attachStopCallback(deleteListener, useSafe) this.attachStopCallback(deleteListener, useSafe)
@ -1150,16 +1355,20 @@ export default class App {
this.clearBuffers() this.clearBuffers()
} }
prevOpts: StartOptions = {}
private async _start( private async _start(
startOpts: StartOptions = {}, startOpts: StartOptions = {},
resetByWorker = false, resetByWorker = false,
conditionName?: string, conditionName?: string,
): Promise<StartPromiseReturn> { ): Promise<StartPromiseReturn> {
if (Object.keys(startOpts).length !== 0) {
this.prevOpts = startOpts
}
const isColdStart = this.activityState === ActivityState.ColdStart const isColdStart = this.activityState === ActivityState.ColdStart
if (isColdStart && this.coldInterval) { if (isColdStart && this.coldInterval) {
clearInterval(this.coldInterval) clearInterval(this.coldInterval)
} }
if (!this.worker) { if (!this.worker && !this.insideIframe) {
const reason = 'No worker found: perhaps, CSP is not set.' const reason = 'No worker found: perhaps, CSP is not set.'
this.signalError(reason, []) this.signalError(reason, [])
return Promise.resolve(UnsuccessfulStart(reason)) return Promise.resolve(UnsuccessfulStart(reason))
@ -1191,7 +1400,7 @@ export default class App {
}) })
const timestamp = now() const timestamp = now()
this.worker.postMessage({ this.worker?.postMessage({
type: 'start', type: 'start',
pageNo: this.session.incPageNo(), pageNo: this.session.incPageNo(),
ingestPoint: this.options.ingestPoint, ingestPoint: this.options.ingestPoint,
@ -1239,7 +1448,7 @@ export default class App {
const reason = error === CANCELED ? CANCELED : `Server error: ${r.status}. ${error}` const reason = error === CANCELED ? CANCELED : `Server error: ${r.status}. ${error}`
return UnsuccessfulStart(reason) 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)' const reason = 'no worker found after start request (this should not happen in real world)'
this.signalError(reason, []) this.signalError(reason, [])
return UnsuccessfulStart(reason) return UnsuccessfulStart(reason)
@ -1297,9 +1506,9 @@ export default class App {
if (socketOnly) { if (socketOnly) {
this.socketMode = true this.socketMode = true
this.worker.postMessage('stop') this.worker?.postMessage('stop')
} else { } else {
this.worker.postMessage({ this.worker?.postMessage({
type: 'auth', type: 'auth',
token, token,
beaconSizeLimit, beaconSizeLimit,
@ -1322,11 +1531,17 @@ export default class App {
// TODO: start as early as possible (before receiving the token) // TODO: start as early as possible (before receiving the token)
/** after start */ /** after start */
this.startCallbacks.forEach((cb) => cb(onStartInfo)) // MBTODO: callbacks after DOM "mounted" (observed) this.startCallbacks.forEach((cb) => cb(onStartInfo)) // MBTODO: callbacks after DOM "mounted" (observed)
if (startOpts.startCallback) {
startOpts.startCallback(SuccessfulStart(onStartInfo))
}
if (this.features['feature-flags']) { if (this.features['feature-flags']) {
void this.featureFlags.reloadFlags() void this.featureFlags.reloadFlags()
} }
await this.tagWatcher.fetchTags(this.options.ingestPoint, token) await this.tagWatcher.fetchTags(this.options.ingestPoint, token)
this.activityState = ActivityState.Active this.activityState = ActivityState.Active
if (this.options.crossdomain?.enabled && !this.insideIframe) {
void this.bootChildrenFrames()
}
if (canvasEnabled && !this.options.canvas.disableCanvas) { if (canvasEnabled && !this.options.canvas.disableCanvas) {
this.canvasRecorder = this.canvasRecorder =
@ -1338,7 +1553,6 @@ export default class App {
fixedScaling: this.options.canvas.fixedCanvasScaling, fixedScaling: this.options.canvas.fixedCanvasScaling,
useAnimationFrame: this.options.canvas.useAnimationFrame, useAnimationFrame: this.options.canvas.useAnimationFrame,
}) })
this.canvasRecorder.startTracking()
} }
/** --------------- COLD START BUFFER ------------------*/ /** --------------- COLD START BUFFER ------------------*/
@ -1361,9 +1575,12 @@ export default class App {
} }
this.ticker.start() this.ticker.start()
} }
this.canvasRecorder?.startTracking()
if (this.features['usability-test']) { if (this.features['usability-test'] && !this.insideIframe) {
this.uxtManager = this.uxtManager ? this.uxtManager : new UserTestManager(this, uxtStorageKey) this.uxtManager = this.uxtManager
? this.uxtManager
: new UserTestManager(this, uxtStorageKey)
let uxtId: number | undefined let uxtId: number | undefined
const savedUxtTag = this.localStorage.getItem(uxtStorageKey) const savedUxtTag = this.localStorage.getItem(uxtStorageKey)
if (savedUxtTag) { if (savedUxtTag) {
@ -1396,6 +1613,11 @@ export default class App {
} catch (reason) { } catch (reason) {
this.stop() this.stop()
this.session.reset() this.session.reset()
if (!reason) {
console.error('Unknown error during start')
this.signalError('Unknown error', [])
return UnsuccessfulStart('Unknown error')
}
if (reason === CANCELED) { if (reason === CANCELED) {
this.signalError(CANCELED, []) this.signalError(CANCELED, [])
return UnsuccessfulStart(CANCELED) return UnsuccessfulStart(CANCELED)
@ -1442,21 +1664,23 @@ export default class App {
async waitStart() { async waitStart() {
return new Promise((resolve) => { return new Promise((resolve) => {
const check = () => { const int = setInterval(() => {
if (this.canStart) { if (this.canStart) {
clearInterval(int)
resolve(true) resolve(true)
} else {
setTimeout(check, 25)
} }
} }, 100)
check()
}) })
} }
async waitStarted() { async waitStarted() {
return this.waitStatus(ActivityState.Active)
}
async waitStatus(status: ActivityState) {
return new Promise((resolve) => { return new Promise((resolve) => {
const check = () => { const check = () => {
if (this.activityState === ActivityState.Active) { if (this.activityState === status) {
resolve(true) resolve(true)
} else { } else {
setTimeout(check, 25) setTimeout(check, 25)
@ -1480,6 +1704,10 @@ export default class App {
return Promise.resolve(UnsuccessfulStart(reason)) return Promise.resolve(UnsuccessfulStart(reason))
} }
if (this.insideIframe) {
this.signalIframeTracker()
}
if (!document.hidden) { if (!document.hidden) {
await this.waitStart() await this.waitStart()
return this._start(...args) return this._start(...args)
@ -1535,20 +1763,25 @@ export default class App {
stop(stopWorker = true): void { stop(stopWorker = true): void {
if (this.activityState !== ActivityState.NotActive) { if (this.activityState !== ActivityState.NotActive) {
try { try {
if (!this.insideIframe && this.options.crossdomain?.enabled) {
this.killChildrenFrames()
}
this.attributeSender.clear() this.attributeSender.clear()
this.sanitizer.clear() this.sanitizer.clear()
this.observer.disconnect() this.observer.disconnect()
this.nodes.clear() this.nodes.clear()
this.ticker.stop() this.ticker.stop()
this.stopCallbacks.forEach((cb) => cb()) this.stopCallbacks.forEach((cb) => cb())
this.debug.log('OpenReplay tracking stopped.')
this.tagWatcher.clear() this.tagWatcher.clear()
if (this.worker && stopWorker) { if (this.worker && stopWorker) {
this.worker.postMessage('stop') this.worker.postMessage('stop')
} }
this.canvasRecorder?.clear() this.canvasRecorder?.clear()
this.messages.length = 0
this.parentActive = false
} finally { } finally {
this.activityState = ActivityState.NotActive this.activityState = ActivityState.NotActive
this.debug.log('OpenReplay tracking stopped.')
} }
} }
} }

View file

@ -19,6 +19,13 @@ export default class Logger {
return this.level >= level return this.level >= level
} }
info = (...args: any[]) => {
if (this.shouldLog(LogLevel.Verbose)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
console.info(...args)
}
}
log = (...args: any[]) => { log = (...args: any[]) => {
if (this.shouldLog(LogLevel.Log)) { if (this.shouldLog(LogLevel.Log)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument // eslint-disable-next-line @typescript-eslint/no-unsafe-argument

View file

@ -1,19 +1,34 @@
import { createEventListener, deleteEventListener } from '../utils.js' import { createEventListener, deleteEventListener } from '../../utils.js'
import Maintainer, { MaintainerOptions } from './maintainer.js'
type NodeCallback = (node: Node, isStart: boolean) => void type NodeCallback = (node: Node, isStart: boolean) => void
type ElementListener = [string, EventListener, boolean] type ElementListener = [string, EventListener, boolean]
export interface NodesOptions {
node_id: string
forceNgOff: boolean
maintainer?: Partial<MaintainerOptions>
}
export default class Nodes { export default class Nodes {
private nodes: Array<Node | void> = [] private readonly nodes: Map<number, Node | void> = new Map()
private totalNodeAmount = 0 private totalNodeAmount = 0
private readonly nodeCallbacks: Array<NodeCallback> = [] private readonly nodeCallbacks: Array<NodeCallback> = []
private readonly elementListeners: Map<number, Array<ElementListener>> = new Map() private readonly elementListeners: Map<number, Array<ElementListener>> = new Map()
private nextNodeId = 0 private nextNodeId = 0
private readonly node_id: string
private readonly forceNgOff: boolean
private readonly maintainer: Maintainer
constructor(private readonly node_id: string) {} constructor(params: NodesOptions) {
this.node_id = params.node_id
this.forceNgOff = params.forceNgOff
this.maintainer = new Maintainer(this.nodes, this.unregisterNode, params.maintainer)
this.maintainer.start()
}
syntheticMode(frameOrder: number) { syntheticMode(frameOrder: number) {
const maxSafeNumber = 9007199254740900 const maxSafeNumber = Number.MAX_SAFE_INTEGER
const placeholderSize = 99999999 const placeholderSize = 99999999
const nextFrameId = placeholderSize * frameOrder const nextFrameId = placeholderSize * frameOrder
// I highly doubt that this will ever happen, // I highly doubt that this will ever happen,
@ -25,20 +40,25 @@ export default class Nodes {
} }
// Attached once per Tracker instance // Attached once per Tracker instance
attachNodeCallback(nodeCallback: NodeCallback): void { attachNodeCallback(nodeCallback: NodeCallback): number {
this.nodeCallbacks.push(nodeCallback) return this.nodeCallbacks.push(nodeCallback)
} }
scanTree = (cb: (node: Node | void) => void) => { scanTree = (cb: (node: Node | void) => void) => {
this.nodes.forEach((node) => cb(node)) this.nodes.forEach((node) => (node ? cb(node) : undefined))
} }
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) const id = this.getID(node)
if (id === undefined) { if (id === undefined) {
return return
} }
createEventListener(node, type, listener, useCapture) createEventListener(node, type, listener, useCapture, this.forceNgOff)
let listeners = this.elementListeners.get(id) let listeners = this.elementListeners.get(id)
if (listeners === undefined) { if (listeners === undefined) {
listeners = [] listeners = []
@ -54,23 +74,23 @@ export default class Nodes {
id = this.nextNodeId id = this.nextNodeId
this.totalNodeAmount++ this.totalNodeAmount++
this.nextNodeId++ this.nextNodeId++
this.nodes[id] = node this.nodes.set(id, node)
;(node as any)[this.node_id] = id ;(node as any)[this.node_id] = id
} }
return [id, isNew] return [id, isNew]
} }
unregisterNode(node: Node): number | undefined { unregisterNode = (node: Node): number | undefined => {
const id = (node as any)[this.node_id] const id = (node as any)[this.node_id]
if (id !== undefined) { if (id !== undefined) {
;(node as any)[this.node_id] = undefined ;(node as any)[this.node_id] = undefined
delete (node as any)[this.node_id] delete (node as any)[this.node_id]
delete this.nodes[id] this.nodes.delete(id)
const listeners = this.elementListeners.get(id) const listeners = this.elementListeners.get(id)
if (listeners !== undefined) { if (listeners !== undefined) {
this.elementListeners.delete(id) this.elementListeners.delete(id)
listeners.forEach((listener) => listeners.forEach((listener) =>
deleteEventListener(node, listener[0], listener[1], listener[2]), deleteEventListener(node, listener[0], listener[1], listener[2], this.forceNgOff),
) )
} }
this.totalNodeAmount-- this.totalNodeAmount--
@ -83,8 +103,7 @@ export default class Nodes {
// but its still better than keeping dead nodes or undef elements // but its still better than keeping dead nodes or undef elements
// plus we keep our index positions for new/alive nodes // plus we keep our index positions for new/alive nodes
// performance test: 3ms for 30k nodes with 17k dead ones // performance test: 3ms for 30k nodes with 17k dead ones
for (let i = 0; i < this.nodes.length; i++) { for (const [_, node] of this.nodes) {
const node = this.nodes[i]
if (node && !document.contains(node)) { if (node && !document.contains(node)) {
this.unregisterNode(node) this.unregisterNode(node)
} }
@ -101,7 +120,7 @@ export default class Nodes {
} }
getNode(id: number) { getNode(id: number) {
return this.nodes[id] return this.nodes.get(id)
} }
getNodeCount() { getNodeCount() {
@ -109,14 +128,13 @@ export default class Nodes {
} }
clear(): void { clear(): void {
for (let id = 0; id < this.nodes.length; id++) { for (const [_, node] of this.nodes) {
const node = this.nodes[id] if (node) {
if (!node) { this.unregisterNode(node)
continue
} }
this.unregisterNode(node)
} }
this.nextNodeId = 0 this.nextNodeId = 0
this.nodes.length = 0 this.nodes.clear()
} }
} }

View file

@ -0,0 +1,122 @@
const SECOND = 1000
function processMapInBatches(
map: Map<number, Node | void>,
batchSize: number,
processBatchCallback: (node: Node) => void,
) {
const iterator = map.entries()
function processNextBatch() {
const batch = []
let result = iterator.next()
while (!result.done && batch.length < batchSize) {
batch.push(result.value)
result = iterator.next()
}
if (batch.length > 0) {
batch.forEach(([_, node]) => {
if (node) {
processBatchCallback(node)
}
})
setTimeout(processNextBatch, 50)
}
}
processNextBatch()
}
function isNodeStillActive(node: Node): [isCon: boolean, reason: string] {
try {
if (!node.isConnected) {
return [false, 'not connected']
}
const nodeIsDocument = node.nodeType === Node.DOCUMENT_NODE
const nodeWindow = nodeIsDocument
? (node as Document).defaultView
: node.ownerDocument?.defaultView
const ownerDoc = nodeIsDocument ? (node as Document) : node.ownerDocument
if (!nodeWindow) {
return [false, 'no window']
}
if (nodeWindow.closed) {
return [false, 'window closed']
}
if (!ownerDoc?.documentElement.isConnected) {
return [false, 'documentElement not connected']
}
return [true, 'ok']
} catch (e) {
return [false, e]
}
}
export interface MaintainerOptions {
/**
* Run cleanup each X ms
*
* @default 30 * 1000
* */
interval: number
/**
* Maintainer checks nodes in small batches over 50ms timeouts
*
* @default 2500
* */
batchSize: number
/**
* @default true
* */
enabled: boolean
}
const defaults = {
interval: SECOND * 30,
batchSize: 2500,
enabled: true,
}
class Maintainer {
private interval: ReturnType<typeof setInterval>
private readonly options: MaintainerOptions
constructor(
private readonly nodes: Map<number, Node | void>,
private readonly unregisterNode: (node: Node) => void,
options?: Partial<MaintainerOptions>,
) {
this.options = { ...defaults, ...options }
}
public start = () => {
if (!this.options.enabled) {
return
}
this.stop()
this.interval = setInterval(() => {
processMapInBatches(this.nodes, this.options.batchSize, (node) => {
const isActive = isNodeStillActive(node)[0]
if (!isActive) {
this.unregisterNode(node)
}
})
}, this.options.interval)
}
public stop = () => {
if (this.interval) {
clearInterval(this.interval)
}
}
}
export default Maintainer

View file

@ -1,13 +1,14 @@
import Observer from './observer.js' import Observer from './observer.js'
import { CreateIFrameDocument } from '../messages.gen.js' import { CreateIFrameDocument, RemoveNode } from '../messages.gen.js'
export default class IFrameObserver extends Observer { export default class IFrameObserver extends Observer {
docId: number | undefined
observe(iframe: HTMLIFrameElement) { observe(iframe: HTMLIFrameElement) {
const doc = iframe.contentDocument const doc = iframe.contentDocument
const hostID = this.app.nodes.getID(iframe) const hostID = this.app.nodes.getID(iframe)
if (!doc || hostID === undefined) { if (!doc || hostID === undefined) {
return return
} //log TODO common app.logger }
// Have to observe document, because the inner <html> might be changed // Have to observe document, because the inner <html> might be changed
this.observeRoot(doc, (docID) => { this.observeRoot(doc, (docID) => {
//MBTODO: do not send if empty (send on load? it might be in-place iframe, like our replayer, which does not get loaded) //MBTODO: do not send if empty (send on load? it might be in-place iframe, like our replayer, which does not get loaded)
@ -15,17 +16,18 @@ export default class IFrameObserver extends Observer {
this.app.debug.log('OpenReplay: Iframe document not bound') this.app.debug.log('OpenReplay: Iframe document not bound')
return return
} }
this.docId = docID
this.app.send(CreateIFrameDocument(hostID, docID)) this.app.send(CreateIFrameDocument(hostID, docID))
}) })
} }
syntheticObserve(selfId: number, doc: Document) { syntheticObserve(rootNodeId: number, doc: Document) {
this.observeRoot(doc, (docID) => { this.observeRoot(doc, (docID) => {
if (docID === undefined) { if (docID === undefined) {
this.app.debug.log('OpenReplay: Iframe document not bound') this.app.debug.log('OpenReplay: Iframe document not bound')
return return
} }
this.app.send(CreateIFrameDocument(selfId, docID)) this.app.send(CreateIFrameDocument(rootNodeId, docID))
}) })
} }
} }

View file

@ -8,7 +8,7 @@ type OffsetState = {
} }
export default class IFrameOffsets { export default class IFrameOffsets {
private readonly states: Map<Document, OffsetState> = new Map() private states: WeakMap<Document, OffsetState> = new WeakMap()
private calcOffset(state: OffsetState): Offset { private calcOffset(state: OffsetState): Offset {
let parLeft = 0, let parLeft = 0,
@ -55,12 +55,10 @@ export default class IFrameOffsets {
// anything more reliable? This does not cover all cases (layout changes are ignored, for ex.) // anything more reliable? This does not cover all cases (layout changes are ignored, for ex.)
parentDoc.addEventListener('scroll', invalidateOffset) parentDoc.addEventListener('scroll', invalidateOffset)
parentDoc.defaultView?.addEventListener('resize', invalidateOffset) parentDoc.defaultView?.addEventListener('resize', invalidateOffset)
this.states.set(doc, state) this.states.set(doc, state)
} }
clear() { clear() {
this.states.forEach((s) => s.clear()) this.states = new WeakMap()
this.states.clear()
} }
} }

View file

@ -1,4 +1,4 @@
import { createMutationObserver, ngSafeBrowserMethod } from '../../utils.js' import { createMutationObserver } from '../../utils.js'
import { import {
RemoveNodeAttribute, RemoveNodeAttribute,
SetNodeAttributeURLBased, SetNodeAttributeURLBased,
@ -77,13 +77,11 @@ export default abstract class Observer {
// mutations order is sequential // mutations order is sequential
const target = mutation.target const target = mutation.target
const type = mutation.type const type = mutation.type
if (!isObservable(target)) { if (!isObservable(target)) {
continue continue
} }
if (type === 'childList') { if (type === 'childList') {
for (let i = 0; i < mutation.removedNodes.length; i++) { 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])) { if (isObservable(mutation.removedNodes[i])) {
this.bindNode(mutation.removedNodes[i]) this.bindNode(mutation.removedNodes[i])
} }
@ -114,13 +112,14 @@ export default abstract class Observer {
} }
if (type === 'characterData') { if (type === 'characterData') {
this.textSet.add(id) this.textSet.add(id)
continue
} }
} }
this.commitNodes() this.commitNodes()
}) as MutationCallback, }) as MutationCallback,
this.app.options.forceNgOff,
) )
} }
private clear(): void { private clear(): void {
this.commited.length = 0 this.commited.length = 0
this.recents.clear() this.recents.clear()
@ -129,10 +128,51 @@ export default abstract class Observer {
this.textSet.clear() this.textSet.clear()
} }
/**
* EXPERIMENTAL: Unbinds the removed nodes in case of iframe src change.
*
* right now, we're relying on nodes.maintainer
*/
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 { private sendNodeAttribute(id: number, node: Element, name: string, value: string | null): void {
if (isSVGElement(node)) { if (isSVGElement(node)) {
if (name.substr(0, 6) === 'xlink:') { if (name.substring(0, 6) === 'xlink:') {
name = name.substr(6) name = name.substring(6)
} }
if (value === null) { if (value === null) {
this.app.send(RemoveNodeAttribute(id, name)) this.app.send(RemoveNodeAttribute(id, name))
@ -152,7 +192,7 @@ export default abstract class Observer {
name === 'integrity' || name === 'integrity' ||
name === 'crossorigin' || name === 'crossorigin' ||
name === 'autocomplete' || name === 'autocomplete' ||
name.substr(0, 2) === 'on' name.substring(0, 2) === 'on'
) { ) {
return return
} }
@ -357,6 +397,7 @@ export default abstract class Observer {
} }
return true return true
} }
private commitNode(id: number): boolean { private commitNode(id: number): boolean {
const node = this.app.nodes.getNode(id) const node = this.app.nodes.getNode(id)
if (node === undefined) { if (node === undefined) {
@ -368,6 +409,7 @@ export default abstract class Observer {
} }
return (this.commited[id] = this._commitNode(id, node)) return (this.commited[id] = this._commitNode(id, node))
} }
private commitNodes(isStart = false): void { private commitNodes(isStart = false): void {
let node let node
this.recents.forEach((type, id) => { this.recents.forEach((type, id) => {

View file

@ -1,6 +1,5 @@
import Observer from './observer.js' import Observer from './observer.js'
import { isElementNode, hasTag } from '../guards.js' import { isElementNode, hasTag } from '../guards.js'
import Network from '../../modules/network.js'
import IFrameObserver from './iframe_observer.js' import IFrameObserver from './iframe_observer.js'
import ShadowRootObserver from './shadow_root_observer.js' import ShadowRootObserver from './shadow_root_observer.js'
@ -22,16 +21,17 @@ const attachShadowNativeFn = IN_BROWSER ? Element.prototype.attachShadow : () =>
export default class TopObserver extends Observer { export default class TopObserver extends Observer {
private readonly options: Options private readonly options: Options
private readonly iframeOffsets: IFrameOffsets = new IFrameOffsets() private readonly iframeOffsets: IFrameOffsets = new IFrameOffsets()
readonly app: App
constructor(app: App, options: Partial<Options>) { constructor(params: { app: App; options: Partial<Options> }) {
super(app, true) super(params.app, true)
this.app = params.app
this.options = Object.assign( this.options = Object.assign(
{ {
captureIFrames: true, captureIFrames: true,
}, },
options, params.options,
) )
// IFrames // IFrames
this.app.nodes.attachNodeCallback((node) => { this.app.nodes.attachNodeCallback((node) => {
if ( if (
@ -54,7 +54,7 @@ export default class TopObserver extends Observer {
private readonly contextCallbacks: Array<ContextCallback> = [] private readonly contextCallbacks: Array<ContextCallback> = []
// Attached once per Tracker instance // Attached once per Tracker instance
private readonly contextsSet: Set<Window> = new Set() private readonly contextsSet: WeakSet<Window> = new WeakSet()
attachContextCallback(cb: ContextCallback) { attachContextCallback(cb: ContextCallback) {
this.contextCallbacks.push(cb) this.contextCallbacks.push(cb)
} }
@ -63,29 +63,34 @@ export default class TopObserver extends Observer {
return this.iframeOffsets.getDocumentOffset(doc) return this.iframeOffsets.getDocumentOffset(doc)
} }
private iframeObservers: IFrameObserver[] = [] private iframeObserversArr: IFrameObserver[] = []
private iframeObservers: WeakMap<HTMLIFrameElement | Document, IFrameObserver> = new WeakMap()
private docObservers: WeakMap<Document, IFrameObserver> = new WeakMap()
private handleIframe(iframe: HTMLIFrameElement): void { private handleIframe(iframe: HTMLIFrameElement): void {
let doc: Document | null = null
// setTimeout is required. Otherwise some event listeners (scroll, mousemove) applied in modules // setTimeout is required. Otherwise some event listeners (scroll, mousemove) applied in modules
// do not work on the iframe document when it 've been loaded dynamically ((why?)) // do not work on the iframe document when it 've been loaded dynamically ((why?))
const handle = this.app.safe(() => const handle = this.app.safe(() =>
setTimeout(() => { setTimeout(() => {
const id = this.app.nodes.getID(iframe) const id = this.app.nodes.getID(iframe)
if (id === undefined) { if (id === undefined || !canAccessIframe(iframe)) return
//log
return
}
if (!canAccessIframe(iframe)) return
const currentWin = iframe.contentWindow const currentWin = iframe.contentWindow
const currentDoc = iframe.contentDocument const currentDoc = iframe.contentDocument
if (currentDoc && currentDoc !== doc) { if (!currentDoc) {
const observer = new IFrameObserver(this.app) this.app.debug.warn('no doc for iframe found', iframe)
this.iframeObservers.push(observer) return
observer.observe(iframe) // TODO: call unregisterNode for the previous doc if present (incapsulate: one iframe - one observer)
doc = currentDoc
this.iframeOffsets.observe(iframe)
} }
if (currentDoc && this.docObservers.has(currentDoc)) {
this.app.debug.info('doc already observed for', id)
return
}
const observer = new IFrameObserver(this.app)
this.iframeObservers.set(iframe, observer)
this.docObservers.set(currentDoc, observer)
this.iframeObserversArr.push(observer)
observer.observe(iframe)
this.iframeOffsets.observe(iframe)
if ( if (
currentWin && currentWin &&
// Sometimes currentWin.window is null (not in specification). Such window object is not functional // Sometimes currentWin.window is null (not in specification). Such window object is not functional
@ -94,20 +99,20 @@ export default class TopObserver extends Observer {
//TODO: more explicit logic //TODO: more explicit logic
) { ) {
this.contextsSet.add(currentWin) this.contextsSet.add(currentWin)
//@ts-ignore https://github.com/microsoft/TypeScript/issues/41684 // @ts-ignore https://github.com/microsoft/TypeScript/issues/41684
this.contextCallbacks.forEach((cb) => cb(currentWin)) this.contextCallbacks.forEach((cb) => cb(currentWin))
} }
// we need this delay because few iframes stacked one in another with rapid updates will break the player (or browser engine rather?) // we need this delay because few iframes stacked one in another with rapid updates will break the player (or browser engine rather?)
}, 100), }, 250),
) )
iframe.addEventListener('load', handle) // why app.attachEventListener not working? iframe.addEventListener('load', handle)
handle() handle()
} }
private shadowRootObservers: ShadowRootObserver[] = [] private shadowRootObservers: WeakMap<ShadowRoot, ShadowRootObserver> = new WeakMap()
private handleShadowRoot(shRoot: ShadowRoot) { private handleShadowRoot(shRoot: ShadowRoot) {
const observer = new ShadowRootObserver(this.app) const observer = new ShadowRootObserver(this.app)
this.shadowRootObservers.push(observer) this.shadowRootObservers.set(shRoot, observer)
observer.observe(shRoot.host) observer.observe(shRoot.host)
} }
@ -121,7 +126,6 @@ export default class TopObserver extends Observer {
observer.handleShadowRoot(shadow) observer.handleShadowRoot(shadow)
return shadow return shadow
} }
this.app.nodes.clear() this.app.nodes.clear()
// Can observe documentElement (<html>) here, because it is not supposed to be changing. // Can observe documentElement (<html>) here, because it is not supposed to be changing.
// However, it is possible in some exotic cases and may cause an ignorance of the newly created <html> // However, it is possible in some exotic cases and may cause an ignorance of the newly created <html>
@ -140,7 +144,7 @@ export default class TopObserver extends Observer {
) )
} }
crossdomainObserve(selfId: number, frameOder: number) { crossdomainObserve(rootNodeId: number, frameOder: number) {
const observer = this const observer = this
Element.prototype.attachShadow = function () { Element.prototype.attachShadow = function () {
// eslint-disable-next-line // eslint-disable-next-line
@ -151,17 +155,18 @@ export default class TopObserver extends Observer {
this.app.nodes.clear() this.app.nodes.clear()
this.app.nodes.syntheticMode(frameOder) this.app.nodes.syntheticMode(frameOder)
const iframeObserver = new IFrameObserver(this.app) const iframeObserver = new IFrameObserver(this.app)
this.iframeObservers.push(iframeObserver) this.iframeObservers.set(window.document, iframeObserver)
iframeObserver.syntheticObserve(selfId, window.document) iframeObserver.syntheticObserve(rootNodeId, window.document)
} }
disconnect() { disconnect() {
this.iframeOffsets.clear() this.iframeOffsets.clear()
Element.prototype.attachShadow = attachShadowNativeFn Element.prototype.attachShadow = attachShadowNativeFn
this.iframeObservers.forEach((o) => o.disconnect()) this.iframeObserversArr.forEach((observer) => observer.disconnect())
this.iframeObservers = [] this.iframeObserversArr = []
this.shadowRootObservers.forEach((o) => o.disconnect()) this.iframeObservers = new WeakMap()
this.shadowRootObservers = [] this.shadowRootObservers = new WeakMap()
this.docObservers = new WeakMap()
super.disconnect() super.disconnect()
} }
} }

View file

@ -23,17 +23,16 @@ export default class Sanitizer {
private readonly obscured: Set<number> = new Set() private readonly obscured: Set<number> = new Set()
private readonly hidden: Set<number> = new Set() private readonly hidden: Set<number> = new Set()
private readonly options: Options private readonly options: Options
private readonly app: App
constructor( constructor(params: { app: App; options?: Partial<Options> }) {
private readonly app: App, this.app = params.app
options: Partial<Options>,
) {
this.options = Object.assign( this.options = Object.assign(
{ {
obscureTextEmails: true, obscureTextEmails: true,
obscureTextNumbers: false, obscureTextNumbers: false,
}, },
options, params.options,
) )
} }

View file

@ -35,11 +35,13 @@ export default class Session {
private tabId: string private tabId: string
public userInfo: UserInfo public userInfo: UserInfo
private token: string | undefined private token: string | undefined
private readonly app: App
private readonly options: Options
constructor(params: { app: App; options: Options }) {
this.app = params.app
this.options = params.options
constructor(
private readonly app: App,
private readonly options: Options,
) {
this.createTabId() this.createTabId()
} }

View file

@ -95,6 +95,14 @@ function processOptions(obj: any): obj is Options {
return true return true
} }
const canAccessTop = () => {
try {
return Boolean(window.top)
} catch {
return false
}
}
export default class API { export default class API {
public featureFlags: FeatureFlags public featureFlags: FeatureFlags
@ -104,9 +112,13 @@ export default class API {
constructor(private readonly options: Options) { constructor(private readonly options: Options) {
this.crossdomainMode = Boolean(inIframe() && options.crossdomain?.enabled) this.crossdomainMode = Boolean(inIframe() && options.crossdomain?.enabled)
if (!IN_BROWSER || !processOptions(options)) { if (!IN_BROWSER || !processOptions(options)) {
console.error('OpenReplay: tracker called in a non-browser environment or with invalid options')
return return
} }
if ((window as any).__OPENREPLAY__) { if (
(window as any).__OPENREPLAY__ ||
(!this.crossdomainMode && inIframe() && canAccessTop() && (window.top as any)?.__OPENREPLAY__)
) {
console.error('OpenReplay: one tracker instance has been initialised already') console.error('OpenReplay: one tracker instance has been initialised already')
return return
} }

View file

@ -17,11 +17,13 @@ export class StringDictionary {
export default class AttributeSender { export default class AttributeSender {
private dict = new StringDictionary() private dict = new StringDictionary()
private readonly app: App
private readonly isDictDisabled: boolean
constructor( constructor(options: { app: App; isDictDisabled: boolean }) {
private readonly app: App, this.app = options.app
private readonly isDictDisabled: boolean, this.isDictDisabled = options.isDictDisabled
) {} }
public sendSetAttribute(id: number, name: string, value: string) { public sendSetAttribute(id: number, name: string, value: string) {
if (this.isDictDisabled) { if (this.isDictDisabled) {

View file

@ -1,7 +1,7 @@
import type App from '../app/index.js' import type App from '../app/index.js'
import type Message from '../app/messages.gen.js' import type Message from '../app/messages.gen.js'
import { JSException } from '../app/messages.gen.js' import { JSException } from '../app/messages.gen.js'
import ErrorStackParser from 'error-stack-parser' import { parse } from 'error-stack-parser-es'
export interface Options { export interface Options {
captureExceptions: boolean captureExceptions: boolean
@ -34,7 +34,7 @@ export function getExceptionMessage(
): Message { ): Message {
let stack = fallbackStack let stack = fallbackStack
try { try {
stack = ErrorStackParser.parse(error) stack = parse(error)
} catch (e) {} } catch (e) {}
return JSException(error.name, error.message, JSON.stringify(stack), JSON.stringify(metadata)) return JSException(error.name, error.message, JSON.stringify(stack), JSON.stringify(metadata))
} }
@ -90,8 +90,12 @@ export default function (app: App, opts: Partial<Options>): void {
app.send(msg) app.send(msg)
} }
} }
app.attachEventListener(context, 'unhandledrejection', handler) try {
app.attachEventListener(context, 'error', handler) app.attachEventListener(context, 'unhandledrejection', handler)
app.attachEventListener(context, 'error', handler)
} catch (e) {
console.error('Error while attaching to error proto contexts', e)
}
} }
if (options.captureExceptions) { if (options.captureExceptions) {
app.observer.attachContextCallback(patchContext) // TODO: attach once-per-iframe (?) app.observer.attachContextCallback(patchContext) // TODO: attach once-per-iframe (?)

View file

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

View file

@ -136,7 +136,7 @@ export default function (app: App, options?: MouseHandlerOptions): void {
let direction = 0 let direction = 0
let directionChangeCount = 0 let directionChangeCount = 0
let distance = 0 let distance = 0
let checkIntervalId: NodeJS.Timer let checkIntervalId: ReturnType<typeof setInterval>
const shakeThreshold = 0.008 const shakeThreshold = 0.008
const shakeCheckInterval = 225 const shakeCheckInterval = 225

View file

@ -4,14 +4,21 @@ class TagWatcher {
intervals: Record<string, ReturnType<typeof setInterval>> = {} intervals: Record<string, ReturnType<typeof setInterval>> = {}
tags: { id: number; selector: string }[] = [] tags: { id: number; selector: string }[] = []
observer: IntersectionObserver observer: IntersectionObserver
private readonly sessionStorage: Storage
private readonly errLog: (args: any[]) => void
private readonly onTag: (tag: number) => void
constructor( constructor(params: {
private readonly sessionStorage: Storage, sessionStorage: Storage
private readonly errLog: (args: any[]) => void, errLog: (args: any[]) => void
private readonly onTag: (tag: number) => void, onTag: (tag: number) => void
) { }) {
this.sessionStorage = params.sessionStorage
this.errLog = params.errLog
this.onTag = params.onTag
// @ts-ignore
const tags: { id: number; selector: string }[] = JSON.parse( const tags: { id: number; selector: string }[] = JSON.parse(
sessionStorage.getItem(WATCHED_TAGS_KEY) ?? '[]', params.sessionStorage.getItem(WATCHED_TAGS_KEY) ?? '[]',
) )
this.setTags(tags) this.setTags(tags)
this.observer = new IntersectionObserver((entries) => { this.observer = new IntersectionObserver((entries) => {

View file

@ -2,6 +2,7 @@
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"module": "CommonJS", "module": "CommonJS",
"outDir": "../../build/cjs" "moduleResolution": "Node",
"declarationDir": "../../dist/cjs"
}, },
} }

View file

@ -3,7 +3,7 @@
"compilerOptions": { "compilerOptions": {
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"lib": ["es2020", "dom"], "lib": ["es2020", "dom"],
"declaration": true "declaration": true,
"declarationDir": "../../dist/lib",
}, },
"references": [{ "path": "../common" }] }
}

View file

@ -95,6 +95,29 @@ export function canAccessIframe(iframe: HTMLIFrameElement) {
} }
} }
export function canAccessTarget(target: EventTarget): boolean {
try {
if (target instanceof HTMLIFrameElement) {
void target.contentDocument
} else if (target instanceof Window) {
void target.document
} else if (target instanceof Document) {
void target.defaultView
} else if ('nodeType' in target) {
void (target as Node).nodeType
} else if ('addEventListener' in target) {
void (target as EventTarget).addEventListener
}
return true
} catch (e) {
if (e instanceof DOMException && e.name === 'SecurityError') {
return false
}
}
return true
}
function dec2hex(dec: number) { function dec2hex(dec: number) {
return dec.toString(16).padStart(2, '0') return dec.toString(16).padStart(2, '0')
} }
@ -132,9 +155,13 @@ export function ngSafeBrowserMethod(method: string): string {
: method : method
} }
export function createMutationObserver(cb: MutationCallback) { export function createMutationObserver(cb: MutationCallback, forceNgOff?: boolean) {
const mObserver = ngSafeBrowserMethod('MutationObserver') as 'MutationObserver' if (!forceNgOff) {
return new window[mObserver](cb) const mObserver = ngSafeBrowserMethod('MutationObserver') as 'MutationObserver'
return new window[mObserver](cb)
} else {
return new MutationObserver(cb)
}
} }
export function createEventListener( export function createEventListener(
@ -142,15 +169,31 @@ export function createEventListener(
event: string, event: string,
cb: EventListenerOrEventListenerObject, cb: EventListenerOrEventListenerObject,
capture?: boolean, capture?: boolean,
forceNgOff?: boolean,
) { ) {
const safeAddEventListener = ngSafeBrowserMethod('addEventListener') as 'addEventListener' // we need to check if target is crossorigin frame or no and if we can access it
if (!canAccessTarget(target)) {
return
}
let safeAddEventListener = 'addEventListener' as unknown as 'addEventListener'
if (!forceNgOff) {
safeAddEventListener = ngSafeBrowserMethod('addEventListener') as 'addEventListener'
}
try { try {
target[safeAddEventListener](event, cb, capture) // parent has angular, but child frame don't
if (target[safeAddEventListener]) {
target[safeAddEventListener](event, cb, capture)
} else {
// @ts-ignore
target.addEventListener(event, cb, capture)
}
} catch (e) { } catch (e) {
const msg = e.message const msg = e.message
console.debug( console.error(
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
`Openreplay: ${msg}; if this error is caused by an IframeObserver, ignore it`, `Openreplay: ${msg}; if this error is caused by an IframeObserver, ignore it`,
event,
target,
) )
} }
} }
@ -160,17 +203,29 @@ export function deleteEventListener(
event: string, event: string,
cb: EventListenerOrEventListenerObject, cb: EventListenerOrEventListenerObject,
capture?: boolean, capture?: boolean,
forceNgOff?: boolean,
) { ) {
const safeRemoveEventListener = ngSafeBrowserMethod( if (!canAccessTarget(target)) {
'removeEventListener', return
) as 'removeEventListener' }
let safeRemoveEventListener = 'removeEventListener' as unknown as 'removeEventListener'
if (!forceNgOff) {
safeRemoveEventListener = ngSafeBrowserMethod('removeEventListener') as 'removeEventListener'
}
try { try {
target[safeRemoveEventListener](event, cb, capture) if (target[safeRemoveEventListener]) {
target[safeRemoveEventListener](event, cb, capture)
} else {
// @ts-ignore
target.removeEventListener(event, cb, capture)
}
} catch (e) { } catch (e) {
const msg = e.message const msg = e.message
console.debug( console.error(
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
`Openreplay: ${msg}; if this error is caused by an IframeObserver, ignore it`, `Openreplay: ${msg}; if this error is caused by an IframeObserver, ignore it`,
event,
target,
) )
} }
} }

View file

@ -10,7 +10,10 @@ describe('AttributeSender', () => {
appMock = { appMock = {
send: (...args: any[]) => args, send: (...args: any[]) => args,
} }
attributeSender = new AttributeSender(appMock, false) attributeSender = new AttributeSender({
app: appMock,
isDictDisabled: false,
})
}) })
afterEach(() => { afterEach(() => {

View file

@ -1,4 +1,4 @@
import FeatureFlags, { FetchPersistFlagsData, IFeatureFlag } from '../main/modules/FeatureFlags' import FeatureFlags, { FetchPersistFlagsData, IFeatureFlag } from '../main/modules/featureFlags'
import { describe, expect, jest, afterEach, beforeEach, test } from '@jest/globals' import { describe, expect, jest, afterEach, beforeEach, test } from '@jest/globals'
jest.mock('../main/app/index.js') jest.mock('../main/app/index.js')

View file

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

View file

@ -21,8 +21,11 @@ describe('Sanitizer', () => {
getID: (el: { mockId: number }) => el.mockId, getID: (el: { mockId: number }) => el.mockId,
}, },
} }
// @ts-expect-error sanitizer = new Sanitizer({
sanitizer = new Sanitizer(app, options) // @ts-expect-error
app,
options,
})
}) })
afterEach(() => { afterEach(() => {
@ -78,7 +81,7 @@ describe('Sanitizer', () => {
} }
// @ts-expect-error // @ts-expect-error
sanitizer = new Sanitizer(app, options) sanitizer = new Sanitizer({ app, options })
const spanNode = document.createElement('span') const spanNode = document.createElement('span')
const divNode = document.createElement('div') const divNode = document.createElement('div')

View file

@ -1,5 +1,5 @@
import { jest, test, describe, beforeEach, afterEach, expect } from '@jest/globals' import { jest, test, describe, beforeEach, afterEach, expect } from '@jest/globals'
import Session from '../main/app/Session' import Session from '../main/app/session'
import App from '../main/app/index.js' import App from '../main/app/index.js'
import { generateRandomId } from '../main/utils.js' import { generateRandomId } from '../main/utils.js'
@ -34,7 +34,10 @@ describe('Session', () => {
// @ts-ignore // @ts-ignore
generateRandomId.mockReturnValue('random_id') generateRandomId.mockReturnValue('random_id')
session = new Session(mockApp as unknown as App, mockOptions) session = new Session({
app: mockApp as App,
options: mockOptions,
})
}) })
afterEach(() => { afterEach(() => {

View file

@ -1,8 +1,12 @@
import TagWatcher, { WATCHED_TAGS_KEY } from '../main/modules/TagWatcher' import TagWatcher, { WATCHED_TAGS_KEY } from '../main/modules/tagWatcher'
import { describe, expect, jest, afterEach, beforeEach, test } from '@jest/globals' import { describe, expect, jest, afterEach, beforeEach, test } from '@jest/globals'
const getMockSaved = () => '[{"id":1,"selector":"div"},{"id":2,"selector":"span"}]'
describe('TagWatcher', () => { describe('TagWatcher', () => {
let sessionStorageMock: Storage const sessionStorageMock = {
getItem: getMockSaved,
setItem: jest.fn(),
} as unknown as Storage
let errLogMock: (args: any[]) => void let errLogMock: (args: any[]) => void
const onTag = jest.fn() const onTag = jest.fn()
let mockObserve: Function let mockObserve: Function
@ -10,11 +14,6 @@ describe('TagWatcher', () => {
let mockDisconnect: Function let mockDisconnect: Function
beforeEach(() => { beforeEach(() => {
sessionStorageMock = {
// @ts-ignore
getItem: jest.fn(),
setItem: jest.fn(),
}
errLogMock = jest.fn() errLogMock = jest.fn()
mockObserve = jest.fn() mockObserve = jest.fn()
mockUnobserve = jest.fn() mockUnobserve = jest.fn()
@ -46,11 +45,9 @@ describe('TagWatcher', () => {
} }
test('constructor initializes with tags from sessionStorage', () => { test('constructor initializes with tags from sessionStorage', () => {
// @ts-ignore const watcher = new TagWatcher({
sessionStorageMock.getItem.mockReturnValue( sessionStorage: sessionStorageMock, errLog: errLogMock, onTag
'[{"id":1,"selector":"div"},{"id":2,"selector":"span"}]', })
)
const watcher = new TagWatcher(sessionStorageMock, errLogMock, onTag)
expect(watcher.tags).toEqual([ expect(watcher.tags).toEqual([
{ id: 1, selector: 'div' }, { id: 1, selector: 'div' },
{ id: 2, selector: 'span' }, { id: 2, selector: 'span' },
@ -73,7 +70,9 @@ describe('TagWatcher', () => {
}), }),
}), }),
) )
const watcher = new TagWatcher(sessionStorageMock, errLogMock, onTag) const watcher = new TagWatcher({
sessionStorage: sessionStorageMock, errLog: errLogMock, onTag
})
await watcher.fetchTags('https://localhost.com', '123') await watcher.fetchTags('https://localhost.com', '123')
expect(watcher.tags).toEqual([ expect(watcher.tags).toEqual([
{ id: 1, selector: 'div' }, { id: 1, selector: 'div' },
@ -87,7 +86,9 @@ describe('TagWatcher', () => {
}) })
test('setTags sets intervals for each tag', () => { test('setTags sets intervals for each tag', () => {
const watcher = new TagWatcher(sessionStorageMock, errLogMock, onTag) const watcher = new TagWatcher({
sessionStorage: sessionStorageMock, errLog: errLogMock, onTag
})
watcher.setTags([ watcher.setTags([
{ id: 1, selector: 'div' }, { id: 1, selector: 'div' },
{ id: 2, selector: 'p' }, { id: 2, selector: 'p' },
@ -98,7 +99,9 @@ describe('TagWatcher', () => {
}) })
test('onTagRendered sends messages', () => { test('onTagRendered sends messages', () => {
const watcher = new TagWatcher(sessionStorageMock, errLogMock, onTag) const watcher = new TagWatcher({
sessionStorage: sessionStorageMock, errLog: errLogMock, onTag
})
watcher.setTags([{ id: 1, selector: 'div' }]) watcher.setTags([{ id: 1, selector: 'div' }])
// @ts-ignore // @ts-ignore
document.querySelectorAll.mockReturnValue([{ __or_watcher_tagname: 'div' }]) // Mock a found element document.querySelectorAll.mockReturnValue([{ __or_watcher_tagname: 'div' }]) // Mock a found element
@ -109,7 +112,9 @@ describe('TagWatcher', () => {
}) })
test('clear method clears all intervals and resets tags', () => { test('clear method clears all intervals and resets tags', () => {
const watcher = new TagWatcher(sessionStorageMock, errLogMock, onTag) const watcher = new TagWatcher({
sessionStorage: sessionStorageMock, errLog: errLogMock, onTag
})
watcher.setTags([ watcher.setTags([
{ id: 1, selector: 'div' }, { id: 1, selector: 'div' },
{ id: 2, selector: 'p' }, { id: 2, selector: 'p' },

View file

@ -1,7 +1,12 @@
{ {
"extends": "../../tsconfig-base.json", "extends": "../../tsconfig-base.json",
"compilerOptions": { "compilerOptions": {
"lib": ["es6"] "lib": ["ES2020", "webworker"],
"module": "ESNext",
"moduleResolution": "Node",
"target": "es2016",
"preserveConstEnums": false,
"declaration": false
}, },
"references": [{ "path": "../common" }] "references": [{ "path": "../../common" }]
} }

View file

@ -1,16 +1,16 @@
{ {
"compilerOptions": { "compilerOptions": {
"rootDir": "src", "rootDir": "src",
"outDir": "build",
"declaration": true,
"noImplicitAny": true, "noImplicitAny": true,
"noImplicitThis": true, "noImplicitThis": true,
"strictNullChecks": true, "strictNullChecks": true,
"alwaysStrict": true, "alwaysStrict": true,
"target": "es2020", "target": "es2020",
"module": "es6", "module": "NodeNext",
"moduleResolution": "node", "moduleResolution": "NodeNext",
"esModuleInterop": true, "esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
}, },
"exclude": ["**/*.test.ts"] "exclude": ["**/*.test.ts"],
} }