feat(tracker): linting hook for tracker

This commit is contained in:
sylenien 2022-07-25 12:21:06 +02:00
parent 544e05a081
commit c254178631
54 changed files with 1688 additions and 1537 deletions

View file

@ -13,7 +13,7 @@ export const durationFormatted = (duration: Duration):string => {
duration = duration.toFormat('h\'h\'m\'m');
} else if (duration.as('months') < 1) { // show in days and hours
duration = duration.toFormat('d\'d\'h\'h');
} else { //
} else {
duration = duration.toFormat('m\'m\'s\'s\'');
}
@ -133,4 +133,4 @@ export const countDaysFrom = (timestamp: number): number => {
const date = DateTime.fromMillis(timestamp);
const d = new Date();
return Math.round(Math.abs(d.getTime() - date.toJSDate().getTime()) / (1000 * 3600 * 24));
}
}

25
tracker/.husky/pre-commit Executable file
View file

@ -0,0 +1,25 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
if git diff --cached --name-only | grep --quiet '^tracker/tracker/'
then
echo "tracker"
pwd
cd tracker/tracker
npm run lint-front
cd ../../
fi
if git diff --cached --name-only | grep --quiet '^tracker/tracker-assist/'
then
echo "tracker-assist"
cd tracker/tracker-assist
npm run lint-front
cd ../../
fi
exit 0

View file

@ -0,0 +1,8 @@
node_modules
npm-debug.log
lib
cjs
build
.cache
.eslintrc.cjs
src/common/messages.ts

View file

@ -0,0 +1,49 @@
/* eslint-disable */
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: __dirname,
},
plugins: ['@typescript-eslint', 'prettier'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/eslint-recommended',
'plugin:@typescript-eslint/recommended',
'plugin:@typescript-eslint/recommended-requiring-type-checking',
'prettier',
],
rules: {
'no-empty': [
'error',
{
allowEmptyCatch: true,
},
],
'@typescript-eslint/camelcase': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/unbound-method': 'off',
'@typescript-eslint/explicit-function-return-type': 'warn',
'@typescript-eslint/prefer-readonly': 'warn',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/restrict-plus-operands': 'warn',
'@typescript-eslint/no-unsafe-return': 'warn',
'no-useless-escape': 'warn',
'no-control-regex': 'warn',
'@typescript-eslint/restrict-template-expressions': 'warn',
'@typescript-eslint/no-useless-constructor': 'warn',
'@typescript-eslint/no-this-alias': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
'no-unused-expressions': 'off',
'@typescript-eslint/no-unused-expressions': 'warn',
'@typescript-eslint/no-useless-constructor': 'warn',
'semi': ["error", "never"],
'quotes': ["error", "single"],
'comma-dangle': ["error", "always"]
},
};

View file

@ -13,7 +13,7 @@
"type": "module",
"main": "./lib/index.js",
"scripts": {
"lint": "prettier --write 'src/**/*.ts' README.md && tsc --noEmit",
"lint": "eslint src --ext .ts,.js --fix --quiet",
"build": "npm run build-es && npm run build-cjs",
"build-es": "rm -Rf lib && tsc && npm run replace-versions",
"build-cjs": "rm -Rf cjs && tsc --project tsconfig-cjs.json && echo '{ \"type\": \"commonjs\" }' > cjs/package.json && npm run replace-paths && npm run replace-versions",
@ -21,7 +21,9 @@
"replace-versions": "npm run replace-pkg-version && npm run replace-req-version",
"replace-pkg-version": "replace-in-files lib/* cjs/* --string='PACKAGE_VERSION' --replacement=$npm_package_version",
"replace-req-version": "replace-in-files lib/* cjs/* --string='REQUIRED_TRACKER_VERSION' --replacement='3.5.14'",
"prepublishOnly": "npm run build"
"prepublishOnly": "npm run build",
"prepare": "cd ../../ && husky install tracker/.husky/",
"lint-front": "lint-staged"
},
"dependencies": {
"csstype": "^3.0.10",
@ -32,9 +34,26 @@
"@openreplay/tracker": "^3.5.3"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^5.30.0",
"@typescript-eslint/parser": "^5.30.0",
"eslint": "^7.8.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-prettier": "^4.2.1",
"husky": "^8.0.1",
"lint-staged": "^13.0.3",
"prettier": "^2.7.1",
"@openreplay/tracker": "file:../tracker",
"prettier": "^2.7.0",
"replace-in-files-cli": "^1.0.0",
"typescript": "^4.6.0-dev.20211126"
},
"husky": {
"hooks": {
"pre-commit": "sh lint.sh"
}
},
"lint-staged": {
"*.{js,mjs,cjs,jsx,ts,tsx}": [
"eslint --fix --quiet"
]
}
}

View file

@ -1,14 +1,14 @@
export default class AnnotationCanvas {
private canvas: HTMLCanvasElement
private ctx: CanvasRenderingContext2D | null = null
private painting: boolean = false
private painting = false
constructor() {
this.canvas = document.createElement('canvas')
Object.assign(this.canvas.style, {
position: "fixed",
position: 'fixed',
left: 0,
top: 0,
pointerEvents: "none",
pointerEvents: 'none',
zIndex: 2147483647 - 2,
})
}
@ -18,7 +18,7 @@ export default class AnnotationCanvas {
this.canvas.height = window.innerHeight
}
private lastPosition: [number, number] = [0,0]
private lastPosition: [number, number] = [0,0,]
start = (p: [number, number]) => {
this.painting = true
this.clrTmID && clearTimeout(this.clrTmID)
@ -38,9 +38,9 @@ export default class AnnotationCanvas {
this.ctx.moveTo(this.lastPosition[0], this.lastPosition[1])
this.ctx.lineTo(p[0], p[1])
this.ctx.lineWidth = 8
this.ctx.lineCap = "round"
this.ctx.lineJoin = "round"
this.ctx.strokeStyle = "red"
this.ctx.lineCap = 'round'
this.ctx.lineJoin = 'round'
this.ctx.strokeStyle = 'red'
this.ctx.stroke()
this.lastPosition = p
}
@ -51,7 +51,7 @@ export default class AnnotationCanvas {
const fadeStep = () => {
if (!this.ctx || this.painting ) { return }
this.ctx.globalCompositeOperation = 'destination-out'
this.ctx.fillStyle = "rgba(255, 255, 255, 0.1)"
this.ctx.fillStyle = 'rgba(255, 255, 255, 0.1)'
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height)
this.ctx.globalCompositeOperation = 'source-over'
timeoutID = setTimeout(fadeStep,100)
@ -67,8 +67,8 @@ export default class AnnotationCanvas {
mount() {
document.body.appendChild(this.canvas)
this.ctx = this.canvas.getContext("2d")
window.addEventListener("resize", this.resizeCanvas)
this.ctx = this.canvas.getContext('2d')
window.addEventListener('resize', this.resizeCanvas)
this.resizeCanvas()
}
@ -76,6 +76,6 @@ export default class AnnotationCanvas {
if (this.canvas.parentNode){
this.canvas.parentNode.removeChild(this.canvas)
}
window.removeEventListener("resize", this.resizeCanvas)
window.removeEventListener('resize', this.resizeCanvas)
}
}

View file

@ -1,20 +1,21 @@
import type { Socket } from 'socket.io-client';
import { connect } from 'socket.io-client';
import Peer from 'peerjs';
import type { Properties } from 'csstype';
import { App } from '@openreplay/tracker';
/* eslint-disable @typescript-eslint/no-empty-function */
import type { Socket, } from 'socket.io-client'
import { connect, } from 'socket.io-client'
import Peer from 'peerjs'
import type { Properties, } from 'csstype'
import { App, } from '@openreplay/tracker'
import RequestLocalStream from './LocalStream.js';
import RemoteControl from './RemoteControl.js';
import CallWindow from './CallWindow.js';
import AnnotationCanvas from './AnnotationCanvas.js';
import ConfirmWindow from './ConfirmWindow/ConfirmWindow.js';
import { callConfirmDefault } from './ConfirmWindow/defaults.js';
import type { Options as ConfirmOptions } from './ConfirmWindow/defaults.js';
import RequestLocalStream from './LocalStream.js'
import RemoteControl from './RemoteControl.js'
import CallWindow from './CallWindow.js'
import AnnotationCanvas from './AnnotationCanvas.js'
import ConfirmWindow from './ConfirmWindow/ConfirmWindow.js'
import { callConfirmDefault, } from './ConfirmWindow/defaults.js'
import type { Options as ConfirmOptions, } from './ConfirmWindow/defaults.js'
// TODO: fully specified strict check (everywhere)
type StartEndCallback = () => ((()=>{}) | void)
type StartEndCallback = () => ((()=>Record<string, unknown>) | void)
export interface Options {
onAgentConnect: StartEndCallback,
@ -40,7 +41,7 @@ enum CallingState {
// TODO typing????
type OptionalCallback = (()=>{}) | void
type OptionalCallback = (()=>Record<string, unknown>) | void
type Agent = {
onDisconnect?: OptionalCallback,
onControlReleased?: OptionalCallback,
@ -49,11 +50,11 @@ type Agent = {
}
export default class Assist {
readonly version = "PACKAGE_VERSION"
readonly version = 'PACKAGE_VERSION'
private socket: Socket | null = null
private peer: Peer | null = null
private assistDemandedRestart: boolean = false
private assistDemandedRestart = false
private callingState: CallingState = CallingState.False
private agents: Record<string, Agent> = {}
@ -64,8 +65,8 @@ export default class Assist {
private readonly noSecureMode: boolean = false,
) {
this.options = Object.assign({
session_calling_peer_key: "__openreplay_calling_peer",
session_control_peer_key: "__openreplay_control_peer",
session_calling_peer_key: '__openreplay_calling_peer',
session_control_peer_key: '__openreplay_control_peer',
config: null,
onCallStart: ()=>{},
onAgentConnect: ()=>{},
@ -74,10 +75,10 @@ export default class Assist {
controlConfirm: {}, // TODO: clear options passing/merging/overriting
},
options,
);
)
if (document.hidden !== undefined) {
const sendActivityState = () => this.emit("UPDATE_SESSION", { active: !document.hidden })
const sendActivityState = () => this.emit('UPDATE_SESSION', { active: !document.hidden, })
app.attachEventListener(
document,
'visibilitychange',
@ -88,15 +89,15 @@ export default class Assist {
}
const titleNode = document.querySelector('title')
const observer = titleNode && new MutationObserver(() => {
this.emit("UPDATE_SESSION", { pageTitle: document.title })
this.emit('UPDATE_SESSION', { pageTitle: document.title, })
})
app.attachStartCallback(() => {
if (this.assistDemandedRestart) { return; }
if (this.assistDemandedRestart) { return }
this.onStart()
observer && observer.observe(titleNode, { subtree: true, characterData: true, childList: true })
observer && observer.observe(titleNode, { subtree: true, characterData: true, childList: true, })
})
app.attachStopCallback(() => {
if (this.assistDemandedRestart) { return; }
if (this.assistDemandedRestart) { return }
this.clean()
observer && observer.disconnect()
})
@ -104,10 +105,10 @@ export default class Assist {
if (this.agentsConnected) {
// @ts-ignore No need in statistics messages. TODO proper filter
if (messages.length === 2 && messages[0]._id === 0 && messages[1]._id === 49) { return }
this.emit("messages", messages)
this.emit('messages', messages)
}
})
app.session.attachUpdateCallback(sessInfo => this.emit("UPDATE_SESSION", sessInfo))
app.session.attachUpdateCallback(sessInfo => this.emit('UPDATE_SESSION', sessInfo))
}
private emit(ev: string, ...args) {
@ -119,7 +120,7 @@ export default class Assist {
}
private notifyCallEnd() {
this.emit("call_end");
this.emit('call_end')
}
private onRemoteCallEnd = () => {}
@ -131,17 +132,17 @@ export default class Assist {
const socket = this.socket = connect(app.getHost(), {
path: '/ws-assist/socket',
query: {
"peerId": peerID,
"identity": "session",
"sessionInfo": JSON.stringify({
'peerId': peerID,
'identity': 'session',
'sessionInfo': JSON.stringify({
pageTitle: document.title,
active: true,
...this.app.getSessionInfo()
...this.app.getSessionInfo(),
}),
},
transports: ["websocket"],
transports: ['websocket',],
})
socket.onAny((...args) => app.debug.log("Socket:", ...args))
socket.onAny((...args) => app.debug.log('Socket:', ...args))
@ -149,15 +150,15 @@ export default class Assist {
this.options,
id => {
this.agents[id].onControlReleased = this.options.onRemoteControlStart()
this.emit("control_granted", id)
this.emit('control_granted', id)
annot = new AnnotationCanvas()
annot.mount()
},
id => {
const cb = this.agents[id].onControlReleased
delete this.agents[id].onControlReleased
typeof cb === "function" && cb()
this.emit("control_rejected", id)
typeof cb === 'function' && cb()
this.emit('control_rejected', id)
if (annot != null) {
annot.remove()
annot = null
@ -166,41 +167,41 @@ export default class Assist {
)
// TODO: check incoming args
socket.on("request_control", remoteControl.requestControl)
socket.on("release_control", remoteControl.releaseControl)
socket.on("scroll", remoteControl.scroll)
socket.on("click", remoteControl.click)
socket.on("move", remoteControl.move)
socket.on("focus", (clientID, nodeID) => {
socket.on('request_control', remoteControl.requestControl)
socket.on('release_control', remoteControl.releaseControl)
socket.on('scroll', remoteControl.scroll)
socket.on('click', remoteControl.click)
socket.on('move', remoteControl.move)
socket.on('focus', (clientID, nodeID) => {
const el = app.nodes.getNode(nodeID)
if (el instanceof HTMLElement) {
remoteControl.focus(clientID, el)
}
})
socket.on("input", remoteControl.input)
socket.on('input', remoteControl.input)
let annot: AnnotationCanvas | null = null
socket.on("moveAnnotation", (_, p) => annot && annot.move(p)) // TODO: restrict by id
socket.on("startAnnotation", (_, p) => annot && annot.start(p))
socket.on("stopAnnotation", () => annot && annot.stop())
socket.on('moveAnnotation', (_, p) => annot && annot.move(p)) // TODO: restrict by id
socket.on('startAnnotation', (_, p) => annot && annot.start(p))
socket.on('stopAnnotation', () => annot && annot.stop())
socket.on("NEW_AGENT", (id: string, info) => {
socket.on('NEW_AGENT', (id: string, info) => {
this.agents[id] = {
onDisconnect: this.options.onAgentConnect && this.options.onAgentConnect(),
...info, // TODO
}
this.assistDemandedRestart = true
this.app.stop();
this.app.stop()
this.app.start().then(() => { this.assistDemandedRestart = false })
})
socket.on("AGENTS_CONNECTED", (ids: string[]) => {
socket.on('AGENTS_CONNECTED', (ids: string[]) => {
ids.forEach(id =>{
this.agents[id] = {
onDisconnect: this.options.onAgentConnect && this.options.onAgentConnect(),
}
})
this.assistDemandedRestart = true
this.app.stop();
this.app.stop()
this.app.start().then(() => { this.assistDemandedRestart = false })
remoteControl.reconnect(ids)
@ -208,7 +209,7 @@ export default class Assist {
let confirmCall:ConfirmWindow | null = null
socket.on("AGENT_DISCONNECTED", (id) => {
socket.on('AGENT_DISCONNECTED', (id) => {
remoteControl.releaseControl(id)
// close the call also
@ -221,15 +222,15 @@ export default class Assist {
this.agents[id] && this.agents[id].onDisconnect != null && this.agents[id].onDisconnect()
delete this.agents[id]
})
socket.on("NO_AGENT", () => {
socket.on('NO_AGENT', () => {
this.agents = {}
})
socket.on("call_end", () => this.onRemoteCallEnd()) // TODO: check if agent calling id
socket.on('call_end', () => this.onRemoteCallEnd()) // TODO: check if agent calling id
// TODO: fix the code
let agentName = ""
let callingAgent = ""
socket.on("_agent_name",(id, name) => { agentName = name; callingAgent = id })
let agentName = ''
let callingAgent = ''
socket.on('_agent_name',(id, name) => { agentName = name; callingAgent = id })
// PeerJS call (todo: use native WebRTC)
@ -242,27 +243,27 @@ export default class Assist {
if (this.options.config) {
peerOptions['config'] = this.options.config
}
const peer = this.peer = new Peer(peerID, peerOptions);
const peer = this.peer = new Peer(peerID, peerOptions)
// app.debug.log('Peer created: ', peer)
// @ts-ignore
peer.on('error', e => app.debug.warn("Peer error: ", e.type, e))
peer.on('error', e => app.debug.warn('Peer error: ', e.type, e))
peer.on('disconnected', () => peer.reconnect())
peer.on('call', (call) => {
app.debug.log("Call: ", call)
app.debug.log('Call: ', call)
if (this.callingState !== CallingState.False) {
call.close()
//this.notifyCallEnd() // TODO: strictly connect calling peer with agent socket.id
app.debug.warn("Call closed instantly bacause line is busy. CallingState: ", this.callingState)
return;
app.debug.warn('Call closed instantly bacause line is busy. CallingState: ', this.callingState)
return
}
const setCallingState = (newState: CallingState) => {
if (newState === CallingState.True) {
sessionStorage.setItem(this.options.session_calling_peer_key, call.peer);
sessionStorage.setItem(this.options.session_calling_peer_key, call.peer)
} else if (newState === CallingState.False) {
sessionStorage.removeItem(this.options.session_calling_peer_key);
sessionStorage.removeItem(this.options.session_calling_peer_key)
}
this.callingState = newState;
this.callingState = newState
}
let confirmAnswer: Promise<boolean>
@ -278,7 +279,7 @@ export default class Assist {
confirmAnswer = confirmCall.mount()
this.playNotificationSound()
this.onRemoteCallEnd = () => { // if call cancelled by a caller before confirmation
app.debug.log("Received call_end during confirm window opened")
app.debug.log('Received call_end during confirm window opened')
confirmCall?.remove()
setCallingState(CallingState.False)
call.close()
@ -307,7 +308,7 @@ export default class Assist {
const onCallEnd = this.options.onCallStart()
const handleCallEnd = () => {
app.debug.log("Handle Call End")
app.debug.log('Handle Call End')
call.close()
callUI.remove()
annot && annot.remove()
@ -322,27 +323,27 @@ export default class Assist {
this.onRemoteCallEnd = handleCallEnd
call.on('error', e => {
app.debug.warn("Call error:", e)
app.debug.warn('Call error:', e)
initiateCallEnd()
});
})
RequestLocalStream().then(lStream => {
call.on('stream', function(rStream) {
callUI.setRemoteStream(rStream);
callUI.setRemoteStream(rStream)
const onInteraction = () => { // only if hidden?
callUI.playRemote()
document.removeEventListener("click", onInteraction)
document.removeEventListener('click', onInteraction)
}
document.addEventListener("click", onInteraction)
});
document.addEventListener('click', onInteraction)
})
lStream.onVideoTrack(vTrack => {
const sender = call.peerConnection.getSenders().find(s => s.track?.kind === "video")
const sender = call.peerConnection.getSenders().find(s => s.track?.kind === 'video')
if (!sender) {
app.debug.warn("No video sender found")
app.debug.warn('No video sender found')
return
}
app.debug.log("sender found:", sender)
app.debug.log('sender found:', sender)
sender.replaceTrack(vTrack)
})
@ -352,16 +353,16 @@ export default class Assist {
setCallingState(CallingState.True)
})
.catch(e => {
app.debug.warn("Audio mediadevice request error:", e)
app.debug.warn('Audio mediadevice request error:', e)
initiateCallEnd()
});
}).catch(); // in case of Confirm.remove() without any confirmation/decline
});
})
}).catch() // in case of Confirm.remove() without any confirmation/decline
})
}
private playNotificationSound() {
if ('Audio' in window) {
new Audio("https://static.openreplay.com/tracker-assist/notification.mp3")
new Audio('https://static.openreplay.com/tracker-assist/notification.mp3')
.play()
.catch(e => {
this.app.debug.warn(e)
@ -372,11 +373,11 @@ export default class Assist {
private clean() {
if (this.peer) {
this.peer.destroy()
this.app.debug.log("Peer destroyed")
this.app.debug.log('Peer destroyed')
}
if (this.socket) {
this.socket.disconnect()
this.app.debug.log("Socket disconnected")
this.app.debug.log('Socket disconnected')
}
}
}

View file

@ -1,7 +1,7 @@
import type { LocalStream } from './LocalStream.js';
import attachDND from './dnd.js';
import type { LocalStream, } from './LocalStream.js'
import attachDND from './dnd.js'
const SS_START_TS_KEY = "__openreplay_assist_call_start_ts"
const SS_START_TS_KEY = '__openreplay_assist_call_start_ts'
export default class CallWindow {
private iframe: HTMLIFrameElement
@ -21,66 +21,66 @@ export default class CallWindow {
constructor() {
const iframe = this.iframe = document.createElement('iframe')
Object.assign(iframe.style, {
position: "fixed",
position: 'fixed',
zIndex: 2147483647 - 1,
border: "none",
bottom: "10px",
right: "10px",
height: "200px",
width: "200px",
border: 'none',
bottom: '10px',
right: '10px',
height: '200px',
width: '200px',
})
// TODO: find the best attribute name for the ignoring iframes
iframe.setAttribute("data-openreplay-obscured", "")
iframe.setAttribute("data-openreplay-hidden", "")
iframe.setAttribute("data-openreplay-ignore", "")
iframe.setAttribute('data-openreplay-obscured', '')
iframe.setAttribute('data-openreplay-hidden', '')
iframe.setAttribute('data-openreplay-ignore', '')
document.body.appendChild(iframe)
const doc = iframe.contentDocument;
const doc = iframe.contentDocument
if (!doc) {
console.error("OpenReplay: CallWindow iframe document is not reachable.")
return;
console.error('OpenReplay: CallWindow iframe document is not reachable.')
return
}
//const baseHref = "https://static.openreplay.com/tracker-assist/test"
const baseHref = "https://static.openreplay.com/tracker-assist/3.4.4"
this.load = fetch(baseHref + "/index.html")
const baseHref = 'https://static.openreplay.com/tracker-assist/3.4.4'
this.load = fetch(baseHref + '/index.html')
.then(r => r.text())
.then((text) => {
iframe.onload = () => {
const assistSection = doc.getElementById("or-assist")
assistSection?.classList.remove("status-connecting")
const assistSection = doc.getElementById('or-assist')
assistSection?.classList.remove('status-connecting')
//iframe.style.height = doc.body.scrollHeight + 'px';
//iframe.style.width = doc.body.scrollWidth + 'px';
this.adjustIframeSize()
iframe.onload = null;
iframe.onload = null
}
// ?
text = text.replace(/href="css/g, `href="${baseHref}/css`)
doc.open();
doc.write(text);
doc.close();
doc.open()
doc.write(text)
doc.close()
this.vLocal = doc.getElementById("video-local") as (HTMLVideoElement | null);
this.vRemote = doc.getElementById("video-remote") as (HTMLVideoElement | null);
this.videoContainer = doc.getElementById("video-container");
this.vLocal = doc.getElementById('video-local') as (HTMLVideoElement | null)
this.vRemote = doc.getElementById('video-remote') as (HTMLVideoElement | null)
this.videoContainer = doc.getElementById('video-container')
this.audioBtn = doc.getElementById("audio-btn");
this.audioBtn = doc.getElementById('audio-btn')
if (this.audioBtn) {
this.audioBtn.onclick = () => this.toggleAudio();
this.audioBtn.onclick = () => this.toggleAudio()
}
this.videoBtn = doc.getElementById("video-btn");
this.videoBtn = doc.getElementById('video-btn')
if (this.videoBtn) {
this.videoBtn.onclick = () => this.toggleVideo();
this.videoBtn.onclick = () => this.toggleVideo()
}
this.endCallBtn = doc.getElementById("end-call-btn");
this.endCallBtn = doc.getElementById('end-call-btn')
this.agentNameElem = doc.getElementById("agent-name");
this.vPlaceholder = doc.querySelector("#remote-stream p")
this.agentNameElem = doc.getElementById('agent-name')
this.vPlaceholder = doc.querySelector('#remote-stream p')
const tsElem = doc.getElementById("duration");
const tsElem = doc.getElementById('duration')
if (tsElem) {
const startTs = Number(sessionStorage.getItem(SS_START_TS_KEY)) || Date.now()
sessionStorage.setItem(SS_START_TS_KEY, startTs.toString())
@ -90,15 +90,15 @@ export default class CallWindow {
const mins = ~~(secsFull / 60)
const secs = secsFull - mins * 60
tsElem.innerText = `${mins}:${secs < 10 ? 0 : ''}${secs}`
}, 500);
}, 500)
}
const dragArea = doc.querySelector(".drag-area")
const dragArea = doc.querySelector('.drag-area')
if (dragArea) {
// TODO: save coordinates on the new page
attachDND(iframe, dragArea, doc.documentElement)
}
});
})
//this.toggleVideoUI(false)
//this.toggleRemoteVideoUI(false)
@ -107,8 +107,8 @@ export default class CallWindow {
private adjustIframeSize() {
const doc = this.iframe.contentDocument
if (!doc) { return }
this.iframe.style.height = doc.body.scrollHeight + 'px';
this.iframe.style.width = doc.body.scrollWidth + 'px';
this.iframe.style.height = doc.body.scrollHeight + 'px'
this.iframe.style.width = doc.body.scrollWidth + 'px'
}
setCallEndAction(endCall: () => void) {
@ -124,16 +124,16 @@ export default class CallWindow {
setRemoteStream(rStream: MediaStream) {
this.load.then(() => {
if (this.vRemote && !this.vRemote.srcObject) {
this.vRemote.srcObject = rStream;
this.vRemote.srcObject = rStream
if (this.vPlaceholder) {
this.vPlaceholder.innerText = "Video has been paused. Click anywhere to resume.";
this.vPlaceholder.innerText = 'Video has been paused. Click anywhere to resume.'
}
// Hack for audio. Doesen't work inside the iframe because of some magical reasons (check if it is connected to autoplay?)
this.aRemote = document.createElement("audio");
this.aRemote.autoplay = true;
this.aRemote.style.display = "none"
this.aRemote.srcObject = rStream;
this.aRemote = document.createElement('audio')
this.aRemote.autoplay = true
this.aRemote.style.display = 'none'
this.aRemote.srcObject = rStream
document.body.appendChild(this.aRemote)
}
@ -156,9 +156,9 @@ export default class CallWindow {
this.load.then(() => {
if (this.videoContainer) {
if (enable) {
this.videoContainer.classList.add("remote")
this.videoContainer.classList.add('remote')
} else {
this.videoContainer.classList.remove("remote")
this.videoContainer.classList.remove('remote')
}
this.adjustIframeSize()
}
@ -186,11 +186,11 @@ export default class CallWindow {
private toggleAudioUI(enabled: boolean) {
if (!this.audioBtn) { return; }
if (!this.audioBtn) { return }
if (enabled) {
this.audioBtn.classList.remove("muted")
this.audioBtn.classList.remove('muted')
} else {
this.audioBtn.classList.add("muted")
this.audioBtn.classList.add('muted')
}
}
@ -200,18 +200,18 @@ export default class CallWindow {
}
private toggleVideoUI(enabled: boolean) {
if (!this.videoBtn || !this.videoContainer) { return; }
if (!this.videoBtn || !this.videoContainer) { return }
if (enabled) {
this.videoContainer.classList.add("local")
this.videoBtn.classList.remove("off");
this.videoContainer.classList.add('local')
this.videoBtn.classList.remove('off')
} else {
this.videoContainer.classList.remove("local")
this.videoBtn.classList.add("off");
this.videoContainer.classList.remove('local')
this.videoBtn.classList.add('off')
}
this.adjustIframeSize()
}
private videoRequested: boolean = false
private videoRequested = false
private toggleVideo() {
this.localStream?.toggleVideo()
.then(enabled => {

View file

@ -1,4 +1,5 @@
import type { Properties } from 'csstype';
/* eslint-disable @typescript-eslint/no-empty-function */
import type { Properties, } from 'csstype'
export type ButtonOptions =
| HTMLButtonElement
@ -19,45 +20,45 @@ export interface ConfirmWindowOptions {
function makeButton(options: ButtonOptions, defaultStyle?: Properties): HTMLButtonElement {
if (options instanceof HTMLButtonElement) {
return options;
return options
}
const btn = document.createElement("button");
const btn = document.createElement('button')
Object.assign(btn.style, {
padding: "10px 14px",
fontSize: "14px",
borderRadius: "3px",
border: "none",
cursor: "pointer",
display: "flex",
alignItems: "center",
textTransform: "uppercase",
marginRight: "10px"
}, defaultStyle);
if (typeof options === "string") {
btn.innerHTML = options;
padding: '10px 14px',
fontSize: '14px',
borderRadius: '3px',
border: 'none',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
textTransform: 'uppercase',
marginRight: '10px',
}, defaultStyle)
if (typeof options === 'string') {
btn.innerHTML = options
} else {
btn.innerHTML = options.innerHTML;
Object.assign(btn.style, options.style);
btn.innerHTML = options.innerHTML
Object.assign(btn.style, options.style)
}
return btn;
return btn
}
export default class ConfirmWindow {
private wrapper: HTMLDivElement;
constructor(options: ConfirmWindowOptions) {
const wrapper = document.createElement("div");
const popup = document.createElement("div");
const p = document.createElement("p");
p.innerText = options.text;
const buttons = document.createElement("div");
const wrapper = document.createElement('div')
const popup = document.createElement('div')
const p = document.createElement('p')
p.innerText = options.text
const buttons = document.createElement('div')
const confirmBtn = makeButton(options.confirmBtn, {
background: "rgba(0, 167, 47, 1)",
color: "white"
background: 'rgba(0, 167, 47, 1)',
color: 'white',
})
const declineBtn = makeButton(options.declineBtn, {
background: "#FFE9E9",
color: "#CC0000"
background: '#FFE9E9',
color: '#CC0000',
})
buttons.appendChild(confirmBtn)
buttons.appendChild(declineBtn)
@ -66,78 +67,78 @@ export default class ConfirmWindow {
Object.assign(buttons.style, {
marginTop: "10px",
display: "flex",
alignItems: "center",
marginTop: '10px',
display: 'flex',
alignItems: 'center',
// justifyContent: "space-evenly",
backgroundColor: "white",
padding: "10px",
boxShadow: "0px 0px 3.99778px 1.99889px rgba(0, 0, 0, 0.1)",
borderRadius: "6px"
});
backgroundColor: 'white',
padding: '10px',
boxShadow: '0px 0px 3.99778px 1.99889px rgba(0, 0, 0, 0.1)',
borderRadius: '6px',
})
Object.assign(
popup.style,
{
font: "14px 'Roboto', sans-serif",
position: "relative",
pointerEvents: "auto",
margin: "4em auto",
width: "90%",
maxWidth: "fit-content",
padding: "20px",
background: "#F3F3F3",
font: '14px \'Roboto\', sans-serif',
position: 'relative',
pointerEvents: 'auto',
margin: '4em auto',
width: '90%',
maxWidth: 'fit-content',
padding: '20px',
background: '#F3F3F3',
//opacity: ".75",
color: "black",
borderRadius: "3px",
boxShadow: "0px 0px 3.99778px 1.99889px rgba(0, 0, 0, 0.1)"
color: 'black',
borderRadius: '3px',
boxShadow: '0px 0px 3.99778px 1.99889px rgba(0, 0, 0, 0.1)',
},
options.style
);
)
Object.assign(wrapper.style, {
position: "fixed",
position: 'fixed',
left: 0,
top: 0,
height: "100%",
width: "100%",
pointerEvents: "none",
zIndex: 2147483647 - 1
});
height: '100%',
width: '100%',
pointerEvents: 'none',
zIndex: 2147483647 - 1,
})
wrapper.appendChild(popup);
this.wrapper = wrapper;
wrapper.appendChild(popup)
this.wrapper = wrapper
confirmBtn.onclick = () => {
this._remove();
this.resolve(true);
};
this._remove()
this.resolve(true)
}
declineBtn.onclick = () => {
this._remove();
this.resolve(false);
};
this._remove()
this.resolve(false)
}
}
private resolve: (result: boolean) => void = () => {};
private reject: () => void = () => {};
mount(): Promise<boolean> {
document.body.appendChild(this.wrapper);
document.body.appendChild(this.wrapper)
return new Promise((resolve, reject) => {
this.resolve = resolve;
this.reject = reject;
});
this.resolve = resolve
this.reject = reject
})
}
private _remove() {
if (!this.wrapper.parentElement) {
return;
return
}
document.body.removeChild(this.wrapper);
document.body.removeChild(this.wrapper)
}
remove() {
this._remove();
this.reject();
this._remove()
this.reject()
}
}

View file

@ -1,10 +1,10 @@
import { declineCall, acceptCall, cross, remoteControl } from '../icons.js'
import type { ButtonOptions, ConfirmWindowOptions } from './ConfirmWindow.js'
import { declineCall, acceptCall, cross, remoteControl, } from '../icons.js'
import type { ButtonOptions, ConfirmWindowOptions, } from './ConfirmWindow.js'
const TEXT_GRANT_REMORTE_ACCESS = "Grant Remote Control";
const TEXT_REJECT = "Reject";
const TEXT_ANSWER_CALL = `${acceptCall} &#xa0 Answer`;
const TEXT_GRANT_REMORTE_ACCESS = 'Grant Remote Control'
const TEXT_REJECT = 'Reject'
const TEXT_ANSWER_CALL = `${acceptCall} &#xa0 Answer`
export type Options = string | Partial<ConfirmWindowOptions>;
@ -14,15 +14,15 @@ function confirmDefault(
declineBtn: ButtonOptions,
text: string
): ConfirmWindowOptions {
const isStr = typeof opts === "string";
const isStr = typeof opts === 'string'
return Object.assign(
{
text: isStr ? opts : text,
confirmBtn,
declineBtn
declineBtn,
},
isStr ? undefined : opts
);
)
}
export const callConfirmDefault = (opts: Options) =>
@ -30,7 +30,7 @@ export const callConfirmDefault = (opts: Options) =>
opts,
TEXT_ANSWER_CALL,
TEXT_REJECT,
"You have an incoming call. Do you want to answer?"
'You have an incoming call. Do you want to answer?'
)
export const controlConfirmDefault = (opts: Options) =>
@ -38,5 +38,5 @@ export const controlConfirmDefault = (opts: Options) =>
opts,
TEXT_GRANT_REMORTE_ACCESS,
TEXT_REJECT,
"Agent requested remote control. Allow?"
'Agent requested remote control. Allow?'
)

View file

@ -5,44 +5,44 @@ declare global {
}
function dummyTrack(): MediaStreamTrack {
const canvas = document.createElement("canvas")//, { width: 0, height: 0})
const canvas = document.createElement('canvas')//, { width: 0, height: 0})
canvas.width=canvas.height=2 // Doesn't work when 1 (?!)
const ctx = canvas.getContext('2d');
ctx?.fillRect(0, 0, canvas.width, canvas.height);
const ctx = canvas.getContext('2d')
ctx?.fillRect(0, 0, canvas.width, canvas.height)
requestAnimationFrame(function draw(){
ctx?.fillRect(0,0, canvas.width, canvas.height)
requestAnimationFrame(draw);
});
requestAnimationFrame(draw)
})
// Also works. Probably it should be done once connected.
//setTimeout(() => { ctx?.fillRect(0,0, canvas.width, canvas.height) }, 4000)
return canvas.captureStream(60).getTracks()[0];
return canvas.captureStream(60).getTracks()[0]
}
export default function RequestLocalStream(): Promise<LocalStream> {
return navigator.mediaDevices.getUserMedia({ audio:true })
return navigator.mediaDevices.getUserMedia({ audio:true, })
.then(aStream => {
const aTrack = aStream.getAudioTracks()[0]
if (!aTrack) { throw new Error("No audio tracks provided") }
if (!aTrack) { throw new Error('No audio tracks provided') }
return new _LocalStream(aTrack)
})
}
class _LocalStream {
private mediaRequested: boolean = false
private mediaRequested = false
readonly stream: MediaStream
private readonly vdTrack: MediaStreamTrack
constructor(aTrack: MediaStreamTrack) {
this.vdTrack = dummyTrack()
this.stream = new MediaStream([ aTrack, this.vdTrack ])
this.stream = new MediaStream([ aTrack, this.vdTrack, ])
}
toggleVideo(): Promise<boolean> {
if (!this.mediaRequested) {
return navigator.mediaDevices.getUserMedia({video:true})
return navigator.mediaDevices.getUserMedia({video:true,})
.then(vStream => {
const vTrack = vStream.getVideoTracks()[0]
if (!vTrack) {
throw new Error("No video track provided")
throw new Error('No video track provided')
}
this.stream.addTrack(vTrack)
this.stream.removeTrack(this.vdTrack)

View file

@ -3,24 +3,24 @@ type XY = [number, number]
export default class Mouse {
private mouse: HTMLDivElement
private position: [number,number] = [0,0]
private position: [number,number] = [0,0,]
constructor() {
this.mouse = document.createElement('div');
this.mouse = document.createElement('div')
Object.assign(this.mouse.style, {
width: "20px",
height: "20px",
opacity: ".4",
borderRadius: "50%",
position: "absolute",
zIndex: "999998",
background: "radial-gradient(red, transparent)",
});
width: '20px',
height: '20px',
opacity: '.4',
borderRadius: '50%',
position: 'absolute',
zIndex: '999998',
background: 'radial-gradient(red, transparent)',
})
}
mount() {
document.body.appendChild(this.mouse)
window.addEventListener("scroll", this.handleWScroll)
window.addEventListener("resize", this.resetLastScrEl)
window.addEventListener('scroll', this.handleWScroll)
window.addEventListener('resize', this.resetLastScrEl)
}
move(pos: XY) {
@ -28,16 +28,16 @@ export default class Mouse {
this.resetLastScrEl()
}
this.position = pos;
this.position = pos
Object.assign(this.mouse.style, {
left: `${pos[0] || 0}px`,
top: `${pos[1] || 0}px`
top: `${pos[1] || 0}px`,
})
}
getPosition(): XY {
return this.position;
return this.position
}
click(pos: XY) {
@ -51,18 +51,18 @@ export default class Mouse {
}
private readonly pScrEl = document.scrollingElement || document.documentElement // Is it always correct
private lastScrEl: Element | "window" | null = null
private lastScrEl: Element | 'window' | null = null
private resetLastScrEl = () => { this.lastScrEl = null }
private handleWScroll = e => {
if (e.target !== this.lastScrEl &&
this.lastScrEl !== "window") {
this.lastScrEl !== 'window') {
this.resetLastScrEl()
}
}
scroll(delta: XY) {
// what would be the browser-like logic?
const [mouseX, mouseY] = this.position
const [dX, dY] = delta
const [mouseX, mouseY,] = this.position
const [dX, dY,] = delta
let el = this.lastScrEl
@ -72,7 +72,7 @@ export default class Mouse {
el.scrollTop += dY
return // TODO: if not scrolled
}
if (el === "window") {
if (el === 'window') {
window.scroll(this.pScrEl.scrollLeft + dX, this.pScrEl.scrollTop + dY)
return
}
@ -85,7 +85,7 @@ export default class Mouse {
// el.scrollTopMax > 0 // available in firefox
if (el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth) {
const styles = getComputedStyle(el)
if (styles.overflow.indexOf("scroll") >= 0 || styles.overflow.indexOf("auto") >= 0) { // returns true for body in habr.com but it's not scrollable
if (styles.overflow.indexOf('scroll') >= 0 || styles.overflow.indexOf('auto') >= 0) { // returns true for body in habr.com but it's not scrollable
const esl = el.scrollLeft
const est = el.scrollTop
el.scrollLeft += dX
@ -101,14 +101,14 @@ export default class Mouse {
// If not scrolled
window.scroll(this.pScrEl.scrollLeft + dX, this.pScrEl.scrollTop + dY)
this.lastScrEl = "window"
this.lastScrEl = 'window'
}
remove() {
if (this.mouse.parentElement) {
document.body.removeChild(this.mouse);
document.body.removeChild(this.mouse)
}
window.removeEventListener("scroll", this.handleWScroll)
window.removeEventListener("resize", this.resetLastScrEl)
window.removeEventListener('scroll', this.handleWScroll)
window.removeEventListener('resize', this.resetLastScrEl)
}
}

View file

@ -1,7 +1,7 @@
import Mouse from './Mouse.js';
import ConfirmWindow from './ConfirmWindow/ConfirmWindow.js';
import { controlConfirmDefault } from './ConfirmWindow/defaults.js';
import type { Options as AssistOptions } from './Assist';
import Mouse from './Mouse.js'
import ConfirmWindow from './ConfirmWindow/ConfirmWindow.js'
import { controlConfirmDefault, } from './ConfirmWindow/defaults.js'
import type { Options as AssistOptions, } from './Assist'
enum RCStatus {
Disabled,
@ -11,7 +11,7 @@ enum RCStatus {
let setInputValue = function(this: HTMLInputElement | HTMLTextAreaElement, value: string) { this.value = value }
const nativeInputValueDescriptor = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")
const nativeInputValueDescriptor = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')
if (nativeInputValueDescriptor && nativeInputValueDescriptor.set) {
setInputValue = nativeInputValueDescriptor.set
}
@ -93,7 +93,7 @@ export default class RemoteControl {
if (this.focused instanceof HTMLTextAreaElement
|| this.focused instanceof HTMLInputElement) {
setInputValue.call(this.focused, value)
const ev = new Event('input', { bubbles: true})
const ev = new Event('input', { bubbles: true,})
this.focused.dispatchEvent(ev)
} else if (this.focused.isContentEditable) {
this.focused.innerText = value

View file

@ -5,4 +5,4 @@
*/
// @ts-ignore
typeof window !== "undefined" && (window.parcelRequire = window.parcelRequire || undefined);
typeof window !== 'undefined' && (window.parcelRequire = window.parcelRequire || undefined)

View file

@ -9,7 +9,7 @@ export default function attachDND(
dropArea: Element,
) {
dragArea.addEventListener('pointerdown', userPressed, { passive: true })
dragArea.addEventListener('pointerdown', userPressed, { passive: true, })
let bbox,
startX, startY,
@ -20,9 +20,9 @@ export default function attachDND(
startX = event.clientX
startY = event.clientY
bbox = movingEl.getBoundingClientRect()
dropArea.addEventListener('pointermove', userMoved, { passive: true })
dropArea.addEventListener('pointerup', userReleased, { passive: true })
dropArea.addEventListener('pointercancel', userReleased, { passive: true })
dropArea.addEventListener('pointermove', userMoved, { passive: true, })
dropArea.addEventListener('pointerup', userReleased, { passive: true, })
dropArea.addEventListener('pointercancel', userReleased, { passive: true, })
};
/*
@ -46,8 +46,8 @@ export default function attachDND(
}
function userMovedRaf() {
movingEl.style.transform = "translate3d("+deltaX+"px,"+deltaY+"px, 0px)";
raf = null;
movingEl.style.transform = 'translate3d('+deltaX+'px,'+deltaY+'px, 0px)'
raf = null
}
function userReleased() {
@ -58,9 +58,9 @@ export default function attachDND(
cancelAnimationFrame(raf)
raf = null
}
movingEl.style.left = bbox.left + deltaX + "px"
movingEl.style.top = bbox.top + deltaY + "px"
movingEl.style.transform = "translate3d(0px,0px,0px)"
movingEl.style.left = bbox.left + deltaX + 'px'
movingEl.style.top = bbox.top + deltaY + 'px'
movingEl.style.transform = 'translate3d(0px,0px,0px)'
deltaX = deltaY = 0
}
}

View file

@ -4,7 +4,7 @@
export const declineCall = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-telephone" viewBox="0 0 16 16">
<path d="M3.654 1.328a.678.678 0 0 0-1.015-.063L1.605 2.3c-.483.484-.661 1.169-.45 1.77a17.568 17.568 0 0 0 4.168 6.608 17.569 17.569 0 0 0 6.608 4.168c.601.211 1.286.033 1.77-.45l1.034-1.034a.678.678 0 0 0-.063-1.015l-2.307-1.794a.678.678 0 0 0-.58-.122l-2.19.547a1.745 1.745 0 0 1-1.657-.459L5.482 8.062a1.745 1.745 0 0 1-.46-1.657l.548-2.19a.678.678 0 0 0-.122-.58L3.654 1.328zM1.884.511a1.745 1.745 0 0 1 2.612.163L6.29 2.98c.329.423.445.974.315 1.494l-.547 2.19a.678.678 0 0 0 .178.643l2.457 2.457a.678.678 0 0 0 .644.178l2.189-.547a1.745 1.745 0 0 1 1.494.315l2.306 1.794c.829.645.905 1.87.163 2.611l-1.034 1.034c-.74.74-1.846 1.065-2.877.702a18.634 18.634 0 0 1-7.01-4.42 18.634 18.634 0 0 1-4.42-7.009c-.362-1.03-.037-2.137.703-2.877L1.885.511z"/>
</svg>`;
</svg>`
export const acceptCall = declineCall.replace('fill="#ef5261"', 'fill="green"')
@ -13,4 +13,4 @@ export const cross = `<svg xmlns="http://www.w3.org/2000/svg" class="bi bi-x-lg"
<path fill-rule="evenodd" d="M2.146 2.146a.5.5 0 0 0 0 .708l11 11a.5.5 0 0 0 .708-.708l-11-11a.5.5 0 0 0-.708 0Z"/>
</svg>`
export const remoteControl = `<svg fill="green" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><path d="M638.59 368.22l-33.37-211.59c-8.86-50.26-48.4-90.77-100.66-103.13h-.07a803.14 803.14 0 0 0-369 0C83.17 65.86 43.64 106.36 34.78 156.63L1.41 368.22C-8.9 426.73 38.8 480 101.51 480c49.67 0 93.77-30.07 109.48-74.64l7.52-21.36h203l7.49 21.36C444.72 449.93 488.82 480 538.49 480c62.71 0 110.41-53.27 100.1-111.78zm-45.11 54.88c-13.28 15.82-33.33 24.9-55 24.9-36.2 0-68.07-21.41-79.29-53.27l-7.53-21.36-7.52-21.37H195.86l-7.53 21.37-7.53 21.36C169.58 426.59 137.71 448 101.51 448c-21.66 0-41.71-9.08-55-24.9A59.93 59.93 0 0 1 33 373.2l33.28-211c6.66-37.7 36.72-68.14 76.53-77.57a771.07 771.07 0 0 1 354.38 0c39.84 9.42 69.87 39.86 76.42 77l33.47 212.15c3.11 17.64-1.72 35.16-13.6 49.32zm-339.3-218.74h-42.54v-42.54a9.86 9.86 0 0 0-9.82-9.82h-19.64a9.86 9.86 0 0 0-9.82 9.82v42.54h-42.54a9.86 9.86 0 0 0-9.82 9.82v19.64a9.86 9.86 0 0 0 9.82 9.82h42.54v42.54a9.86 9.86 0 0 0 9.82 9.82h19.64a9.86 9.86 0 0 0 9.82-9.82v-42.54h42.54a9.86 9.86 0 0 0 9.82-9.82v-19.64a9.86 9.86 0 0 0-9.82-9.82zM416 224a32 32 0 1 0 32 32 32 32 0 0 0-32-32zm64-64a32 32 0 1 0 32 32 32 32 0 0 0-32-32z"/></svg>`
export const remoteControl = '<svg fill="green" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><path d="M638.59 368.22l-33.37-211.59c-8.86-50.26-48.4-90.77-100.66-103.13h-.07a803.14 803.14 0 0 0-369 0C83.17 65.86 43.64 106.36 34.78 156.63L1.41 368.22C-8.9 426.73 38.8 480 101.51 480c49.67 0 93.77-30.07 109.48-74.64l7.52-21.36h203l7.49 21.36C444.72 449.93 488.82 480 538.49 480c62.71 0 110.41-53.27 100.1-111.78zm-45.11 54.88c-13.28 15.82-33.33 24.9-55 24.9-36.2 0-68.07-21.41-79.29-53.27l-7.53-21.36-7.52-21.37H195.86l-7.53 21.37-7.53 21.36C169.58 426.59 137.71 448 101.51 448c-21.66 0-41.71-9.08-55-24.9A59.93 59.93 0 0 1 33 373.2l33.28-211c6.66-37.7 36.72-68.14 76.53-77.57a771.07 771.07 0 0 1 354.38 0c39.84 9.42 69.87 39.86 76.42 77l33.47 212.15c3.11 17.64-1.72 35.16-13.6 49.32zm-339.3-218.74h-42.54v-42.54a9.86 9.86 0 0 0-9.82-9.82h-19.64a9.86 9.86 0 0 0-9.82 9.82v42.54h-42.54a9.86 9.86 0 0 0-9.82 9.82v19.64a9.86 9.86 0 0 0 9.82 9.82h42.54v42.54a9.86 9.86 0 0 0 9.82 9.82h19.64a9.86 9.86 0 0 0 9.82-9.82v-42.54h42.54a9.86 9.86 0 0 0 9.82-9.82v-19.64a9.86 9.86 0 0 0-9.82-9.82zM416 224a32 32 0 1 0 32 32 32 32 0 0 0-32-32zm64-64a32 32 0 1 0 32 32 32 32 0 0 0-32-32z"/></svg>'

View file

@ -1,7 +1,7 @@
import './_slim.js';
import './_slim.js'
import type { App } from '@openreplay/tracker';
import type { Options } from './Assist.js'
import type { App, } from '@openreplay/tracker'
import type { Options, } from './Assist.js'
import Assist from './Assist.js'
@ -9,13 +9,13 @@ export default function(opts?: Partial<Options>) {
return function(app: App | null, appOptions: { __DISABLE_SECURE_MODE?: boolean } = {}) {
// @ts-ignore
if (app === null || !navigator?.mediaDevices?.getUserMedia) { // 93.04% browsers
return;
}
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")
return
}
app.notify.log("OpenReplay Assist initializing.")
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')
return
}
app.notify.log('OpenReplay Assist initializing.')
const assist = new Assist(app, opts, appOptions.__DISABLE_SECURE_MODE)
app.debug.log(assist)
return assist

View file

@ -0,0 +1,8 @@
node_modules
npm-debug.log
lib
cjs
build
.cache
.eslintrc.cjs
src/common/messages.ts

View file

@ -1,8 +1,10 @@
/* eslint-disable */
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
parserOptions: {
project: ['./tsconfig.json'],
project: ['./tsconfig-base.json', './src/main/tsconfig-cjs.json'],
tsconfigRootDir: __dirname,
},
plugins: ['prettier', '@typescript-eslint'],
extends: [
@ -11,7 +13,7 @@ module.exports = {
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
'plugin:@typescript-eslint/recommended-requiring-type-checking',
'prettier/@typescript-eslint',
'prettier',
],
rules: {
'prettier/prettier': ['error', require('./.prettierrc.json')],
@ -27,8 +29,21 @@ module.exports = {
'@typescript-eslint/unbound-method': 'off',
'@typescript-eslint/explicit-function-return-type': 'warn',
'@typescript-eslint/prefer-readonly': 'warn',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/restrict-plus-operands': 'warn',
'@typescript-eslint/no-unsafe-return': 'warn',
'no-useless-escape': 'warn',
'no-control-regex': 'warn',
'@typescript-eslint/restrict-template-expressions': 'warn',
'@typescript-eslint/no-useless-constructor': 'warn',
'@typescript-eslint/no-this-alias': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
'no-unused-expressions': 'off',
'@typescript-eslint/no-unused-expressions': 'warn',
'@typescript-eslint/no-useless-constructor': 'warn',
},
};
};

View file

@ -1,4 +1,5 @@
{
"printWidth": 100,
"singleQuote": true,
"trailingComma": "all"
}

View file

@ -14,33 +14,50 @@
"type": "module",
"main": "./lib/index.js",
"scripts": {
"lint": "eslint src --ext .ts,.js --fix && tsc --noEmit",
"lint": "eslint src --ext .ts,.js --fix --quiet",
"clean": "rm -Rf build && rm -Rf lib && rm -Rf cjs",
"tsc": "tsc -b src/main && tsc -b src/webworker && tsc --project src/main/tsconfig-cjs.json",
"tscRun": "tsc -b src/main && tsc -b src/webworker && tsc --project src/main/tsconfig-cjs.json",
"rollup": "rollup --config rollup.config.js",
"compile": "node --experimental-modules --experimental-json-modules scripts/compile.cjs",
"build": "npm run clean && npm run tsc && npm run rollup && npm run compile",
"prepare": "node scripts/checkver.cjs && npm run build"
"build": "npm run clean && npm run tscRun && npm run rollup && npm run compile",
"prepare": "cd ../../ && husky install tracker/.husky/",
"lint-front": "lint-staged"
},
"devDependencies": {
"@babel/core": "^7.10.2",
"@rollup/plugin-babel": "^5.0.3",
"@rollup/plugin-node-resolve": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^4.33.0",
"@typescript-eslint/parser": "^4.33.0",
"@typescript-eslint/eslint-plugin": "^5.30.0",
"@typescript-eslint/parser": "^5.30.0",
"eslint": "^7.8.0",
"eslint-plugin-prettier": "^4.1.4",
"prettier": "^2.0.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-prettier": "^4.2.1",
"husky": "^8.0.1",
"lint-staged": "^13.0.3",
"prettier": "^2.7.1",
"replace-in-files": "^2.0.3",
"rollup": "^2.17.0",
"rollup-plugin-terser": "^6.1.0",
"semver": "^6.3.0",
"typescript": "^4.6.0-dev.20211126"
"typescript": "4.6.0-dev.20211126"
},
"dependencies": {
"error-stack-parser": "^2.0.6"
},
"engines": {
"node": ">=14.0"
},
"husky": {
"hooks": {
"pre-commit": "sh lint.sh"
}
},
"lint-staged": {
"*.{js,mjs,jsx,ts,tsx}": [
"eslint --fix --quiet"
],
"*.{json,md,html,js,jsx,ts,tsx}": [
"prettier --write"
]
}
}

View file

@ -12,7 +12,7 @@ async function main() {
await replaceInFiles({
files: 'build/**/*',
from: 'WEBWORKER_BODY',
to: webworker.replace(/'/g, "\\'"),
to: webworker.replace(/'/g, "\\'").replace(/\n/g, ""),
});
await fs.rename('build/main', 'lib');
await fs.rename('build/common', 'lib/common');

View file

@ -1,19 +1,19 @@
export interface Options {
connAttemptCount?: number
connAttemptGap?: number
connAttemptCount?: number;
connAttemptGap?: number;
}
type Start = {
type: "start",
ingestPoint: string
pageNo: number
timestamp: number
} & Options
type: 'start';
ingestPoint: string;
pageNo: number;
timestamp: number;
} & Options;
type Auth = {
type: "auth"
token: string
beaconSizeLimit?: number
}
type: 'auth';
token: string;
beaconSizeLimit?: number;
};
export type WorkerMessageData = null | "stop" | Start | Auth | Array<{ _id: number }>
export type WorkerMessageData = null | 'stop' | Start | Auth | Array<{ _id: number }>;

View file

@ -3,31 +3,32 @@ export function isSVGElement(node: Element): node is SVGElement {
}
export function isElementNode(node: Node): node is Element {
return node.nodeType === Node.ELEMENT_NODE
return node.nodeType === Node.ELEMENT_NODE;
}
export function isTextNode(node: Node): node is Text {
return node.nodeType === Node.TEXT_NODE
return node.nodeType === Node.TEXT_NODE;
}
export function isRootNode(node: Node): boolean {
return node.nodeType === Node.DOCUMENT_NODE ||
node.nodeType === Node.DOCUMENT_FRAGMENT_NODE
return node.nodeType === Node.DOCUMENT_NODE || node.nodeType === Node.DOCUMENT_FRAGMENT_NODE;
}
type TagTypeMap = {
HTML: HTMLHtmlElement
IMG: HTMLImageElement
INPUT: HTMLInputElement
TEXTAREA: HTMLTextAreaElement
SELECT: HTMLSelectElement
LABEL: HTMLLabelElement
IFRAME: HTMLIFrameElement
STYLE: HTMLStyleElement
style: SVGStyleElement
LINK: HTMLLinkElement
}
export function hasTag<T extends keyof TagTypeMap>(el: Node, tagName: T): el is TagTypeMap[typeof tagName] {
return el.nodeName === tagName
HTML: HTMLHtmlElement;
IMG: HTMLImageElement;
INPUT: HTMLInputElement;
TEXTAREA: HTMLTextAreaElement;
SELECT: HTMLSelectElement;
LABEL: HTMLLabelElement;
IFRAME: HTMLIFrameElement;
STYLE: HTMLStyleElement;
style: SVGStyleElement;
LINK: HTMLLinkElement;
};
export function hasTag<T extends keyof TagTypeMap>(
el: Node,
tagName: T,
): el is TagTypeMap[typeof tagName] {
return el.nodeName === tagName;
}

View file

@ -1,45 +1,45 @@
import type Message from "../../common/messages.js";
import { Timestamp, Metadata, UserID } from "../../common/messages.js";
import { timestamp, deprecationWarn } from "../utils.js";
import Nodes from "./nodes.js";
import Observer from "./observer/top_observer.js";
import Sanitizer from "./sanitizer.js";
import Ticker from "./ticker.js";
import Logger, { LogLevel } from "./logger.js";
import Session from "./session.js";
import type Message from '../../common/messages.js';
import { Timestamp, Metadata, UserID } from '../../common/messages.js';
import { timestamp, deprecationWarn } from '../utils.js';
import Nodes from './nodes.js';
import Observer from './observer/top_observer.js';
import Sanitizer from './sanitizer.js';
import Ticker from './ticker.js';
import Logger, { LogLevel } from './logger.js';
import Session from './session.js';
import { deviceMemory, jsHeapSizeLimit } from "../modules/performance.js";
import { deviceMemory, jsHeapSizeLimit } from '../modules/performance.js';
import type { Options as ObserverOptions } from "./observer/top_observer.js";
import type { Options as SanitizerOptions } from "./sanitizer.js";
import type { Options as LoggerOptions } from "./logger.js"
import type { Options as WebworkerOptions, WorkerMessageData } from "../../common/webworker.js";
import type { Options as ObserverOptions } from './observer/top_observer.js';
import type { Options as SanitizerOptions } from './sanitizer.js';
import type { Options as LoggerOptions } from './logger.js';
import type { Options as WebworkerOptions, WorkerMessageData } from '../../common/webworker.js';
// TODO: Unify and clearly describe options logic
export interface StartOptions {
userID?: string,
metadata?: Record<string, string>,
forceNew?: boolean,
userID?: string;
metadata?: Record<string, string>;
forceNew?: boolean;
}
interface OnStartInfo {
sessionID: string,
sessionToken: string,
userUUID: string,
sessionID: string;
sessionToken: string;
userUUID: string;
}
const CANCELED = "canceled" as const
const START_ERROR = ":(" as const
type SuccessfulStart = OnStartInfo & { success: true }
const CANCELED = 'canceled' as const;
const START_ERROR = ':(' as const;
type SuccessfulStart = OnStartInfo & { success: true };
type UnsuccessfulStart = {
reason: typeof CANCELED | string
success: false
}
const UnsuccessfulStart = (reason: string): UnsuccessfulStart => ({ reason, success: false})
const SuccessfulStart = (body: OnStartInfo): SuccessfulStart => ({ ...body, success: true})
export type StartPromiseReturn = SuccessfulStart | UnsuccessfulStart
reason: typeof CANCELED | string;
success: false;
};
const UnsuccessfulStart = (reason: string): UnsuccessfulStart => ({ reason, success: false });
const SuccessfulStart = (body: OnStartInfo): SuccessfulStart => ({ ...body, success: true });
export type StartPromiseReturn = SuccessfulStart | UnsuccessfulStart;
type StartCallback = (i: OnStartInfo) => void
type CommitCallback = (messages: Array<Message>) => void
type StartCallback = (i: OnStartInfo) => void;
type CommitCallback = (messages: Array<Message>) => void;
enum ActivityState {
NotActive,
Starting,
@ -54,7 +54,7 @@ type AppOptions = {
session_reset_key: string;
local_uuid_key: string;
ingestPoint: string;
resourceBaseHref: string | null, // resourceHref?
resourceBaseHref: string | null; // resourceHref?
//resourceURLRewriter: (url: string) => string | boolean,
verbose: boolean;
__is_snippet: boolean;
@ -67,8 +67,7 @@ type AppOptions = {
onStart?: StartCallback;
} & WebworkerOptions;
export type Options = AppOptions & ObserverOptions & SanitizerOptions
export type Options = AppOptions & ObserverOptions & SanitizerOptions;
// TODO: use backendHost only
export const DEFAULT_INGEST_POINT = 'https://api.openreplay.com/ingest';
@ -86,19 +85,18 @@ export default class App {
private readonly messages: Array<Message> = [];
private readonly observer: Observer;
private readonly startCallbacks: Array<StartCallback> = [];
private readonly stopCallbacks: Array<Function> = [];
private readonly stopCallbacks: Array<() => any> = [];
private readonly commitCallbacks: Array<CommitCallback> = [];
private readonly options: AppOptions;
private readonly revID: string;
private activityState: ActivityState = ActivityState.NotActive;
private version = 'TRACKER_VERSION'; // TODO: version compatability check inside each plugin.
private readonly version = 'TRACKER_VERSION'; // TODO: version compatability check inside each plugin.
private readonly worker?: Worker;
constructor(
projectKey: string,
sessionToken: string | null | undefined,
options: Partial<Options>,
) {
// if (options.onStart !== undefined) {
// deprecationWarn("'onStart' option", "tracker.start().then(/* handle session info */)")
// } ?? maybe onStart is good
@ -133,13 +131,14 @@ export default class App {
this.notify = new Logger(this.options.verbose ? LogLevel.Warnings : LogLevel.Silent);
this.session = new Session();
this.session.attachUpdateCallback(({ userID, metadata }) => {
if (userID != null) { // TODO: nullable userID
this.send(new UserID(userID))
if (userID != null) {
// TODO: nullable userID
this.send(new UserID(userID));
}
if (metadata != null) {
Object.entries(metadata).forEach(([key, value]) => this.send(new Metadata(key, value)))
Object.entries(metadata).forEach(([key, value]) => this.send(new Metadata(key, value)));
}
})
});
this.localStorage = this.options.localStorage;
this.sessionStorage = this.options.sessionStorage;
@ -149,57 +148,57 @@ export default class App {
try {
this.worker = new Worker(
URL.createObjectURL(
new Blob([`WEBWORKER_BODY`], { type: 'text/javascript' }),
),
URL.createObjectURL(new Blob(['WEBWORKER_BODY'], { type: 'text/javascript' })),
);
this.worker.onerror = e => {
this._debug("webworker_error", e)
}
this.worker.onerror = (e) => {
this._debug('webworker_error', e);
};
this.worker.onmessage = ({ data }: MessageEvent) => {
if (data === "failed") {
if (data === 'failed') {
this.stop();
this._debug("worker_failed", {}) // add context (from worker)
} else if (data === "restart") {
this._debug('worker_failed', {}); // add context (from worker)
} else if (data === 'restart') {
this.stop();
this.start({ forceNew: true });
}
}
};
const alertWorker = () => {
if (this.worker) {
this.worker.postMessage(null);
}
}
};
// keep better tactics, discard others?
this.attachEventListener(window, 'beforeunload', alertWorker, false);
this.attachEventListener(document.body, 'mouseleave', alertWorker, false, false);
// TODO: stop session after inactivity timeout (make configurable)
this.attachEventListener(document, 'visibilitychange', alertWorker, false);
} catch (e) {
this._debug("worker_start", e);
} catch (e) {
this._debug('worker_start', e);
}
}
private _debug(context: string, e: any) {
if(this.options.__debug_report_edp !== null) {
if (this.options.__debug_report_edp !== null) {
fetch(this.options.__debug_report_edp, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
context,
error: `${e}`
})
error: `${e}`,
}),
});
}
this.debug.error("OpenReplay error: ", context, e)
this.debug.error('OpenReplay error: ', context, e);
}
send(message: Message, urgent = false): void {
if (this.activityState === ActivityState.NotActive) { return }
if (this.activityState === ActivityState.NotActive) {
return;
}
this.messages.push(message);
// TODO: commit on start if there were `urgent` sends;
// TODO: commit on start if there were `urgent` sends;
// Clearify where urgent can be used for;
// Clearify workflow for each type of message in case it was sent before start
// Clearify workflow for each type of message in case it was sent before start
// (like Fetch before start; maybe add an option "preCapture: boolean" or sth alike)
if (this.activityState === ActivityState.Active && urgent) {
this.commit();
@ -209,7 +208,7 @@ export default class App {
if (this.worker && this.messages.length) {
this.messages.unshift(new Timestamp(timestamp()));
this.worker.postMessage(this.messages);
this.commitCallbacks.forEach(cb => cb(this.messages));
this.commitCallbacks.forEach((cb) => cb(this.messages));
this.messages.length = 0;
}
}
@ -220,22 +219,22 @@ export default class App {
try {
fn.apply(this, args);
} catch (e) {
app._debug("safe_fn_call", e)
app._debug('safe_fn_call', e);
// time: timestamp(),
// name: e.name,
// message: e.message,
// stack: e.stack
}
} as any // TODO: correct typing
} as any; // TODO: correct typing
}
attachCommitCallback(cb: CommitCallback): void {
this.commitCallbacks.push(cb)
this.commitCallbacks.push(cb);
}
attachStartCallback(cb: StartCallback): void {
this.startCallbacks.push(cb);
}
attachStopCallback(cb: Function): void {
attachStopCallback(cb: () => any): void {
this.stopCallbacks.push(cb);
}
attachEventListener(
@ -248,24 +247,20 @@ export default class App {
if (useSafe) {
listener = this.safe(listener);
}
this.attachStartCallback(() =>
target.addEventListener(type, listener, useCapture),
);
this.attachStopCallback(() =>
target.removeEventListener(type, listener, useCapture),
);
this.attachStartCallback(() => target.addEventListener(type, listener, useCapture));
this.attachStopCallback(() => target.removeEventListener(type, listener, useCapture));
}
// TODO: full correct semantic
checkRequiredVersion(version: string): boolean {
const reqVer = version.split(/[.-]/)
const ver = this.version.split(/[.-]/)
const reqVer = version.split(/[.-]/);
const ver = this.version.split(/[.-]/);
for (let i = 0; i < 3; i++) {
if (Number(ver[i]) < Number(reqVer[i]) || isNaN(Number(ver[i])) || isNaN(Number(reqVer[i]))) {
return false
return false;
}
}
return true
return true;
}
private getStartInfo() {
@ -276,13 +271,13 @@ export default class App {
timestamp: timestamp(), // shouldn't it be set once?
trackerVersion: this.version,
isSnippet: this.options.__is_snippet,
}
};
}
getSessionInfo() {
return {
...this.session.getInfo(),
...this.getStartInfo()
}
...this.getStartInfo(),
};
}
getSessionToken(): string | undefined {
const token = this.sessionStorage.getItem(this.options.session_token_key);
@ -294,38 +289,39 @@ export default class App {
return this.session.getInfo().sessionID || undefined;
}
getHost(): string {
return new URL(this.options.ingestPoint).hostname
return new URL(this.options.ingestPoint).hostname;
}
getProjectKey(): string {
return this.projectKey
return this.projectKey;
}
getBaseHref(): string {
if (typeof this.options.resourceBaseHref === 'string') {
return this.options.resourceBaseHref
return this.options.resourceBaseHref;
} else if (typeof this.options.resourceBaseHref === 'object') {
//switch between types
}
if (document.baseURI) {
return document.baseURI
return document.baseURI;
}
// IE only
return document.head
?.getElementsByTagName("base")[0]
?.getAttribute("href") || location.origin + location.pathname
return (
document.head?.getElementsByTagName('base')[0]?.getAttribute('href') ||
location.origin + location.pathname
);
}
resolveResourceURL(resourceURL: string): string {
const base = new URL(this.getBaseHref())
base.pathname += "/" + new URL(resourceURL).pathname
base.pathname.replace(/\/+/g, "/")
return base.toString()
const base = new URL(this.getBaseHref());
base.pathname += '/' + new URL(resourceURL).pathname;
base.pathname.replace(/\/+/g, '/');
return base.toString();
}
isServiceURL(url: string): boolean {
return url.startsWith(this.options.ingestPoint)
return url.startsWith(this.options.ingestPoint);
}
active(): boolean {
return this.activityState === ActivityState.Active
return this.activityState === ActivityState.Active;
}
resetNextPageSession(flag: boolean) {
@ -337,14 +333,18 @@ export default class App {
}
private _start(startOpts: StartOptions): Promise<StartPromiseReturn> {
if (!this.worker) {
return Promise.resolve(UnsuccessfulStart("No worker found: perhaps, CSP is not set."))
return Promise.resolve(UnsuccessfulStart('No worker found: perhaps, CSP is not set.'));
}
if (this.activityState !== ActivityState.NotActive) {
return Promise.resolve(UnsuccessfulStart("OpenReplay: trying to call `start()` on the instance that has been started already."))
if (this.activityState !== ActivityState.NotActive) {
return Promise.resolve(
UnsuccessfulStart(
'OpenReplay: trying to call `start()` on the instance that has been started already.',
),
);
}
this.activityState = ActivityState.Starting;
let pageNo: number = 0;
let pageNo = 0;
const pageNoStr = this.sessionStorage.getItem(this.options.session_pageno_key);
if (pageNoStr != null) {
pageNo = parseInt(pageNoStr);
@ -352,95 +352,104 @@ export default class App {
}
this.sessionStorage.setItem(this.options.session_pageno_key, pageNo.toString());
const startInfo = this.getStartInfo()
const startInfo = this.getStartInfo();
const startWorkerMsg: WorkerMessageData = {
type: "start",
type: 'start',
pageNo,
ingestPoint: this.options.ingestPoint,
timestamp: startInfo.timestamp,
connAttemptCount: this.options.connAttemptCount,
connAttemptGap: this.options.connAttemptGap,
}
this.worker.postMessage(startWorkerMsg)
};
this.worker.postMessage(startWorkerMsg);
this.session.update({ // TODO: transparent "session" module logic AND explicit internal api for plugins.
// "updating" with old metadata in order to trigger session's UpdateCallbacks.
this.session.update({
// TODO: transparent "session" module logic AND explicit internal api for plugins.
// "updating" with old metadata in order to trigger session's UpdateCallbacks.
// (for the case of internal .start() calls, like on "restart" webworker signal or assistent connection in tracker-assist )
metadata: startOpts.metadata || this.session.getInfo().metadata,
userID: startOpts.userID,
})
});
const sReset = this.sessionStorage.getItem(this.options.session_reset_key);
this.sessionStorage.removeItem(this.options.session_reset_key);
return window.fetch(this.options.ingestPoint + '/v1/web/start', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...startInfo,
userID: this.session.getInfo().userID,
token: this.sessionStorage.getItem(this.options.session_token_key),
deviceMemory,
jsHeapSizeLimit,
reset: startOpts.forceNew || sReset !== null,
}),
})
.then(r => {
if (r.status === 200) {
return r.json()
} else {
return r.text().then(text => text === CANCELED
? Promise.reject(CANCELED)
: Promise.reject(`Server error: ${r.status}. ${text}`)
);
}
})
.then(r => {
if (!this.worker) {
return Promise.reject("no worker found after start request (this might not happen)");
}
const { token, userUUID, sessionID, beaconSizeLimit } = r;
if (typeof token !== 'string' ||
return window
.fetch(this.options.ingestPoint + '/v1/web/start', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...startInfo,
userID: this.session.getInfo().userID,
token: this.sessionStorage.getItem(this.options.session_token_key),
deviceMemory,
jsHeapSizeLimit,
reset: startOpts.forceNew || sReset !== null,
}),
})
.then((r) => {
if (r.status === 200) {
return r.json();
} else {
return r
.text()
.then((text) =>
text === CANCELED
? Promise.reject(CANCELED)
: Promise.reject(`Server error: ${r.status}. ${text}`),
);
}
})
.then((r) => {
if (!this.worker) {
return Promise.reject('no worker found after start request (this might not happen)');
}
const { token, userUUID, sessionID, beaconSizeLimit } = r;
if (
typeof token !== 'string' ||
typeof userUUID !== 'string' ||
(typeof beaconSizeLimit !== 'number' && typeof beaconSizeLimit !== 'undefined')) {
return Promise.reject(`Incorrect server response: ${ JSON.stringify(r) }`);
}
this.sessionStorage.setItem(this.options.session_token_key, token);
this.localStorage.setItem(this.options.local_uuid_key, userUUID);
this.session.update({ sessionID }) // TODO: no no-explicit 'any'
const startWorkerMsg: WorkerMessageData = {
type: "auth",
token,
beaconSizeLimit
}
this.worker.postMessage(startWorkerMsg)
(typeof beaconSizeLimit !== 'number' && typeof beaconSizeLimit !== 'undefined')
) {
return Promise.reject(`Incorrect server response: ${JSON.stringify(r)}`);
}
this.sessionStorage.setItem(this.options.session_token_key, token);
this.localStorage.setItem(this.options.local_uuid_key, userUUID);
this.session.update({ sessionID }); // TODO: no no-explicit 'any'
const startWorkerMsg: WorkerMessageData = {
type: 'auth',
token,
beaconSizeLimit,
};
this.worker.postMessage(startWorkerMsg);
this.activityState = ActivityState.Active
const onStartInfo = { sessionToken: token, userUUID, sessionID };
this.activityState = ActivityState.Active;
this.startCallbacks.forEach((cb) => cb(onStartInfo)); // TODO: start as early as possible (before receiving the token)
this.observer.observe();
this.ticker.start();
const onStartInfo = { sessionToken: token, userUUID, sessionID };
this.notify.log("OpenReplay tracking started.");
// get rid of onStart ?
if (typeof this.options.onStart === 'function') {
this.options.onStart(onStartInfo)
}
return SuccessfulStart(onStartInfo)
})
.catch(reason => {
this.sessionStorage.removeItem(this.options.session_token_key)
this.stop()
if (reason === CANCELED) { return UnsuccessfulStart(CANCELED) }
this.startCallbacks.forEach((cb) => cb(onStartInfo)); // TODO: start as early as possible (before receiving the token)
this.observer.observe();
this.ticker.start();
this.notify.log("OpenReplay was unable to start. ", reason)
this._debug("session_start", reason)
return UnsuccessfulStart(START_ERROR)
})
this.notify.log('OpenReplay tracking started.');
// get rid of onStart ?
if (typeof this.options.onStart === 'function') {
this.options.onStart(onStartInfo);
}
return SuccessfulStart(onStartInfo);
})
.catch((reason) => {
this.sessionStorage.removeItem(this.options.session_token_key);
this.stop();
if (reason === CANCELED) {
return UnsuccessfulStart(CANCELED);
}
this.notify.log('OpenReplay was unable to start. ', reason);
this._debug('session_start', reason);
return UnsuccessfulStart(START_ERROR);
});
}
start(options: StartOptions = {}): Promise<StartPromiseReturn> {
@ -450,31 +459,31 @@ export default class App {
return new Promise((resolve) => {
const onVisibilityChange = () => {
if (!document.hidden) {
document.removeEventListener("visibilitychange", onVisibilityChange);
resolve(this._start(options))
document.removeEventListener('visibilitychange', onVisibilityChange);
resolve(this._start(options));
}
}
document.addEventListener("visibilitychange", onVisibilityChange);
})
};
document.addEventListener('visibilitychange', onVisibilityChange);
});
}
}
stop(calledFromAPI = false): void {
if (this.activityState !== ActivityState.NotActive) {
try {
this.sanitizer.clear()
this.observer.disconnect()
this.nodes.clear()
this.ticker.stop()
this.stopCallbacks.forEach((cb) => cb())
this.sanitizer.clear();
this.observer.disconnect();
this.nodes.clear();
this.ticker.stop();
this.stopCallbacks.forEach((cb) => cb());
if (calledFromAPI) {
this.session.reset()
this.session.reset();
}
this.notify.log("OpenReplay tracking stopped.")
this.notify.log('OpenReplay tracking stopped.');
if (this.worker) {
this.worker.postMessage("stop")
this.worker.postMessage('stop');
}
} finally {
this.activityState = ActivityState.NotActive
this.activityState = ActivityState.NotActive;
}
}
}

View file

@ -1,4 +1,3 @@
export const LogLevel = {
Verbose: 5,
Log: 4,
@ -6,54 +5,60 @@ export const LogLevel = {
Errors: 2,
Silent: 0,
} as const;
type LogLevel = typeof LogLevel[keyof typeof LogLevel]
type LogLevel = typeof LogLevel[keyof typeof LogLevel];
type CustomLevel = {
error: boolean
warn: boolean
log: boolean
}
error: boolean;
warn: boolean;
log: boolean;
};
function IsCustomLevel(l: LogLevel | CustomLevel): l is CustomLevel {
return typeof l === 'object'
return typeof l === 'object';
}
interface _Options {
level: LogLevel | CustomLevel,
messages?: number[],
level: LogLevel | CustomLevel;
messages?: number[];
}
export type Options = true | _Options | LogLevel
export type Options = true | _Options | LogLevel;
export default class Logger {
private readonly options: _Options;
constructor(options: Options = LogLevel.Silent) {
this.options = options === true
? { level: LogLevel.Verbose }
: typeof options === "number" ? { level: options } : options;
this.options =
options === true
? { level: LogLevel.Verbose }
: typeof options === 'number'
? { level: options }
: options;
}
log(...args: any) {
if (IsCustomLevel(this.options.level)
? this.options.level.log
: this.options.level >= LogLevel.Log) {
console.log(...args)
if (
IsCustomLevel(this.options.level)
? this.options.level.log
: this.options.level >= LogLevel.Log
) {
console.log(...args);
}
}
warn(...args: any) {
if (IsCustomLevel(this.options.level)
? this.options.level.warn
: this.options.level >= LogLevel.Warnings) {
console.warn(...args)
if (
IsCustomLevel(this.options.level)
? this.options.level.warn
: this.options.level >= LogLevel.Warnings
) {
console.warn(...args);
}
}
error(...args: any) {
if (IsCustomLevel(this.options.level)
? this.options.level.error
: this.options.level >= LogLevel.Errors) {
console.error(...args)
if (
IsCustomLevel(this.options.level)
? this.options.level.error
: this.options.level >= LogLevel.Errors
) {
console.error(...args);
}
}
}

View file

@ -13,11 +13,7 @@ export default class Nodes {
attachNodeCallback(nodeCallback: NodeCallback): void {
this.nodeCallbacks.push(nodeCallback);
}
attachElementListener(
type: string,
node: Element,
elementListener: EventListener,
): void {
attachElementListener(type: string, node: Element, elementListener: EventListener): void {
const id = this.getID(node);
if (id === undefined) {
return;
@ -50,9 +46,7 @@ export default class Nodes {
const listeners = this.elementListeners.get(id);
if (listeners !== undefined) {
this.elementListeners.delete(id);
listeners.forEach((listener) =>
node.removeEventListener(listener[0], listener[1]),
);
listeners.forEach((listener) => node.removeEventListener(listener[0], listener[1]));
}
}
return id;

View file

@ -1,19 +1,20 @@
import Observer from "./observer.js";
import { CreateIFrameDocument } from "../../../common/messages.js";
import Observer from './observer.js';
import { CreateIFrameDocument } from '../../../common/messages.js';
export default class IFrameObserver extends Observer {
observe(iframe: HTMLIFrameElement) {
const doc = iframe.contentDocument;
const hostID = this.app.nodes.getID(iframe);
if (!doc || hostID === undefined) { return } //log TODO common app.logger
if (!doc || hostID === undefined) {
return;
} //log TODO common app.logger
// Have to observe document, because the inner <html> might be changed
this.observeRoot(doc, (docID) => {
if (docID === undefined) {
console.log("OpenReplay: Iframe document not bound")
console.log('OpenReplay: Iframe document not bound');
return;
}
this.app.send(CreateIFrameDocument(hostID, docID));
this.app.send(CreateIFrameDocument(hostID, docID));
});
}
}
}

View file

@ -8,40 +8,35 @@ import {
CreateElementNode,
MoveNode,
RemoveNode,
} from "../../../common/messages.js";
import App from "../index.js";
import {
isRootNode,
isTextNode,
isElementNode,
isSVGElement,
hasTag,
} from "../guards.js";
} from '../../../common/messages.js';
import App from '../index.js';
import { isRootNode, isTextNode, isElementNode, isSVGElement, hasTag } from '../guards.js';
function isIgnored(node: Node): boolean {
if (isTextNode(node)) { return false }
if (!isElementNode(node)) { return true }
if (isTextNode(node)) {
return false;
}
if (!isElementNode(node)) {
return true;
}
const tag = node.tagName.toUpperCase();
if (tag === 'LINK') {
const rel = node.getAttribute('rel');
const as = node.getAttribute('as');
return !(rel?.includes('stylesheet') || as === "style" || as === "font");
return !(rel?.includes('stylesheet') || as === 'style' || as === 'font');
}
return (
tag === 'SCRIPT' ||
tag === 'NOSCRIPT' ||
tag === 'META' ||
tag === 'TITLE' ||
tag === 'BASE'
tag === 'SCRIPT' || tag === 'NOSCRIPT' || tag === 'META' || tag === 'TITLE' || tag === 'BASE'
);
}
function isObservable(node: Node): boolean {
if (isRootNode(node)) { return true }
return !isIgnored(node)
if (isRootNode(node)) {
return true;
}
return !isIgnored(node);
}
/*
TODO:
- fix unbinding logic + send all removals first (ensure sequence is correct)
@ -57,14 +52,15 @@ enum RecentsType {
export default abstract class Observer {
private readonly observer: MutationObserver;
private readonly commited: Array<boolean | undefined> = [];
private readonly recents: Map<number, RecentsType> = new Map()
private readonly recents: Map<number, RecentsType> = new Map();
private readonly indexes: Array<number> = [];
private readonly attributesMap: Map<number, Set<string>> = new Map();
private readonly textSet: Set<number> = new Set();
constructor(protected readonly app: App, protected readonly isTopContext = false) {
this.observer = new MutationObserver(
this.app.safe((mutations) => {
for (const mutation of mutations) { // mutations order is sequential
for (const mutation of mutations) {
// mutations order is sequential
const target = mutation.target;
const type = mutation.type;
@ -85,16 +81,16 @@ export default abstract class Observer {
continue;
}
if (!this.recents.has(id)) {
this.recents.set(id, RecentsType.Changed) // TODO only when altered
this.recents.set(id, RecentsType.Changed); // TODO only when altered
}
if (type === 'attributes') {
const name = mutation.attributeName;
if (name === null) {
continue;
}
let attr = this.attributesMap.get(id)
let attr = this.attributesMap.get(id);
if (attr === undefined) {
this.attributesMap.set(id, attr = new Set())
this.attributesMap.set(id, (attr = new Set()));
}
attr.add(name);
continue;
@ -110,18 +106,13 @@ export default abstract class Observer {
}
private clear(): void {
this.commited.length = 0;
this.recents.clear()
this.recents.clear();
this.indexes.length = 1;
this.attributesMap.clear();
this.textSet.clear();
}
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 (name.substr(0, 6) === 'xlink:') {
name = name.substr(6);
@ -150,7 +141,7 @@ export default abstract class Observer {
}
if (
name === 'value' &&
hasTag(node, "INPUT") &&
hasTag(node, 'INPUT') &&
node.type !== 'button' &&
node.type !== 'reset' &&
node.type !== 'submit'
@ -161,7 +152,7 @@ export default abstract class Observer {
this.app.send(new RemoveNodeAttribute(id, name));
return;
}
if (name === 'style' || name === 'href' && hasTag(node, "LINK")) {
if (name === 'style' || (name === 'href' && hasTag(node, 'LINK'))) {
this.app.send(new SetNodeAttributeURLBased(id, name, value, this.app.getBaseHref()));
return;
}
@ -172,26 +163,27 @@ export default abstract class Observer {
}
private sendNodeData(id: number, parentElement: Element, data: string): void {
if (hasTag(parentElement, "STYLE") || hasTag(parentElement, "style")) {
if (hasTag(parentElement, 'STYLE') || hasTag(parentElement, 'style')) {
this.app.send(new SetCSSDataURLBased(id, data, this.app.getBaseHref()));
return;
}
data = this.app.sanitizer.sanitize(id, data)
data = this.app.sanitizer.sanitize(id, data);
this.app.send(new SetNodeData(id, data));
}
private bindNode(node: Node): void {
const [ id, isNew ]= this.app.nodes.registerNode(node);
if (isNew){
this.recents.set(id, RecentsType.New)
} else if (this.recents.get(id) !== RecentsType.New) { // can we do just `else` here?
this.recents.set(id, RecentsType.Removed)
const [id, isNew] = this.app.nodes.registerNode(node);
if (isNew) {
this.recents.set(id, RecentsType.New);
} else if (this.recents.get(id) !== RecentsType.New) {
// can we do just `else` here?
this.recents.set(id, RecentsType.Removed);
}
}
private bindTree(node: Node): void {
if (!isObservable(node)) {
return
return;
}
this.bindNode(node);
const walker = document.createTreeWalker(
@ -229,7 +221,7 @@ export default abstract class Observer {
// Disable parent check for the upper context HTMLHtmlElement, because it is root there... (before)
// TODO: get rid of "special" cases (there is an issue with CreateDocument altered behaviour though)
// TODO: Clean the logic (though now it workd fine)
if (!hasTag(node, "HTML") || !this.isTopContext) {
if (!hasTag(node, 'HTML') || !this.isTopContext) {
if (parent === null) {
// Sometimes one observation contains attribute mutations for the removimg node, which gets ignored here.
// That shouldn't affect the visual rendering ( should it? )
@ -264,15 +256,15 @@ export default abstract class Observer {
if (sibling === null) {
this.indexes[id] = 0;
}
const recentsType = this.recents.get(id)
const isNew = recentsType === RecentsType.New
const index = this.indexes[id]
const recentsType = this.recents.get(id);
const isNew = recentsType === RecentsType.New;
const index = this.indexes[id];
if (index === undefined) {
throw 'commitNode: missing node index';
}
if (isNew) {
if (isElementNode(node)) {
let el: Element = node
let el: Element = node;
if (parentID !== undefined) {
if (this.app.sanitizer.isMaskedContainer(id)) {
const width = el.clientWidth;
@ -282,15 +274,7 @@ export default abstract class Observer {
(el as HTMLElement | SVGElement).style.height = height + 'px';
}
this.app.send(new
CreateElementNode(
id,
parentID,
index,
el.tagName,
isSVGElement(node),
),
);
this.app.send(new CreateElementNode(id, parentID, index, el.tagName, isSVGElement(node)));
}
for (let i = 0; i < el.attributes.length; i++) {
const attr = el.attributes[i];
@ -335,19 +319,23 @@ export default abstract class Observer {
}
return (this.commited[id] = this._commitNode(id, node));
}
private commitNodes(isStart: boolean = false): void {
private commitNodes(isStart = false): void {
let node;
this.recents.forEach((type, id) => {
this.commitNode(id);
if (type === RecentsType.New && (node = this.app.nodes.getNode(id))) {
this.app.nodes.callNodeCallbacks(node, isStart)
this.app.nodes.callNodeCallbacks(node, isStart);
}
})
});
this.clear();
}
// ISSSUE
protected observeRoot(node: Node, beforeCommit: (id?: number) => unknown, nodeToBind: Node = node) {
protected observeRoot(
node: Node,
beforeCommit: (id?: number) => unknown,
nodeToBind: Node = node,
) {
this.observer.observe(node, {
childList: true,
attributes: true,
@ -357,8 +345,8 @@ export default abstract class Observer {
characterDataOldValue: false,
});
this.bindTree(nodeToBind);
beforeCommit(this.app.nodes.getID(node))
this.commitNodes(true)
beforeCommit(this.app.nodes.getID(node));
this.commitNodes(true);
}
disconnect(): void {

View file

@ -1,18 +1,19 @@
import Observer from "./observer.js";
import { CreateIFrameDocument } from "../../../common/messages.js";
import Observer from './observer.js';
import { CreateIFrameDocument } from '../../../common/messages.js';
export default class ShadowRootObserver extends Observer {
observe(el: Element) {
const shRoot = el.shadowRoot;
const hostID = this.app.nodes.getID(el);
if (!shRoot || hostID === undefined) { return } // log
if (!shRoot || hostID === undefined) {
return;
} // log
this.observeRoot(shRoot, (rootID) => {
if (rootID === undefined) {
console.log("OpenReplay: Shadow Root was not bound")
console.log('OpenReplay: Shadow Root was not bound');
return;
}
this.app.send(CreateIFrameDocument(hostID,rootID));
this.app.send(CreateIFrameDocument(hostID, rootID));
});
}
}
}

View file

@ -1,101 +1,112 @@
import Observer from "./observer.js";
import {
isElementNode,
hasTag,
} from "../guards.js";
import Observer from './observer.js';
import { isElementNode, hasTag } from '../guards.js';
import IFrameObserver from "./iframe_observer.js";
import ShadowRootObserver from "./shadow_root_observer.js";
import IFrameObserver from './iframe_observer.js';
import ShadowRootObserver from './shadow_root_observer.js';
import { CreateDocument } from "../../../common/messages.js";
import App from "../index.js";
import { IN_BROWSER, hasOpenreplayAttribute } from '../../utils.js'
import { CreateDocument } from '../../../common/messages.js';
import App from '../index.js';
import { IN_BROWSER, hasOpenreplayAttribute } from '../../utils.js';
export interface Options {
captureIFrames: boolean
captureIFrames: boolean;
}
const attachShadowNativeFn = IN_BROWSER ? Element.prototype.attachShadow : ()=>new ShadowRoot();
const attachShadowNativeFn = IN_BROWSER ? Element.prototype.attachShadow : () => new ShadowRoot();
export default class TopObserver extends Observer {
export default class TopObserver extends Observer {
private readonly options: Options;
constructor(app: App, options: Partial<Options>) {
super(app, true);
this.options = Object.assign({
captureIFrames: true
}, options);
this.options = Object.assign(
{
captureIFrames: true,
},
options,
);
// IFrames
this.app.nodes.attachNodeCallback(node => {
if (hasTag(node, "IFRAME") &&
((this.options.captureIFrames && !hasOpenreplayAttribute(node, "obscured"))
|| hasOpenreplayAttribute(node, "capture"))
this.app.nodes.attachNodeCallback((node) => {
if (
hasTag(node, 'IFRAME') &&
((this.options.captureIFrames && !hasOpenreplayAttribute(node, 'obscured')) ||
hasOpenreplayAttribute(node, 'capture'))
) {
this.handleIframe(node)
this.handleIframe(node);
}
})
});
// ShadowDOM
this.app.nodes.attachNodeCallback(node => {
this.app.nodes.attachNodeCallback((node) => {
if (isElementNode(node) && node.shadowRoot !== null) {
this.handleShadowRoot(node.shadowRoot)
this.handleShadowRoot(node.shadowRoot);
}
})
});
}
private iframeObservers: IFrameObserver[] = [];
private handleIframe(iframe: HTMLIFrameElement): void {
let doc: Document | null = null
let doc: Document | null = null;
const handle = this.app.safe(() => {
const id = this.app.nodes.getID(iframe)
if (id === undefined) { return } //log
if (iframe.contentDocument === doc) { return } // How frequently can it happen?
doc = iframe.contentDocument
if (!doc || !iframe.contentWindow) { return }
const observer = new IFrameObserver(this.app)
const id = this.app.nodes.getID(iframe);
if (id === undefined) {
return;
} //log
if (iframe.contentDocument === doc) {
return;
} // How frequently can it happen?
doc = iframe.contentDocument;
if (!doc || !iframe.contentWindow) {
return;
}
const observer = new IFrameObserver(this.app);
this.iframeObservers.push(observer)
observer.observe(iframe)
})
iframe.addEventListener("load", handle) // why app.attachEventListener not working?
handle()
this.iframeObservers.push(observer);
observer.observe(iframe);
});
iframe.addEventListener('load', handle); // why app.attachEventListener not working?
handle();
}
private shadowRootObservers: ShadowRootObserver[] = []
private shadowRootObservers: ShadowRootObserver[] = [];
private handleShadowRoot(shRoot: ShadowRoot) {
const observer = new ShadowRootObserver(this.app)
this.shadowRootObservers.push(observer)
observer.observe(shRoot.host)
const observer = new ShadowRootObserver(this.app);
this.shadowRootObservers.push(observer);
observer.observe(shRoot.host);
}
observe(): void {
// Protection from several subsequent calls?
const observer = this;
Element.prototype.attachShadow = function() {
const shadow = attachShadowNativeFn.apply(this, arguments)
observer.handleShadowRoot(shadow)
return shadow
}
Element.prototype.attachShadow = function () {
// eslint-disable-next-line
const shadow = attachShadowNativeFn.apply(this, arguments);
observer.handleShadowRoot(shadow);
return shadow;
};
// 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>
// In this case context.document have to be observed, but this will cause
// the change in the re-player behaviour caused by CreateDocument message:
// In this case context.document have to be observed, but this will cause
// the change in the re-player behaviour caused by CreateDocument message:
// the 0-node ("fRoot") will become #document rather than documentElement as it is now.
// Alternatively - observe(#document) then bindNode(documentElement)
this.observeRoot(window.document, () => {
this.app.send(new CreateDocument())
}, window.document.documentElement);
this.observeRoot(
window.document,
() => {
this.app.send(new CreateDocument());
},
window.document.documentElement,
);
}
disconnect() {
Element.prototype.attachShadow = attachShadowNativeFn
this.iframeObservers.forEach(o => o.disconnect())
this.iframeObservers = []
this.shadowRootObservers.forEach(o => o.disconnect())
this.shadowRootObservers = []
super.disconnect()
Element.prototype.attachShadow = attachShadowNativeFn;
this.iframeObservers.forEach((o) => o.disconnect());
this.iframeObservers = [];
this.shadowRootObservers.forEach((o) => o.disconnect());
this.shadowRootObservers = [];
super.disconnect();
}
}
}

View file

@ -1,6 +1,6 @@
import type App from "./index.js";
import { stars, hasOpenreplayAttribute } from "../utils.js";
import { isElementNode } from "./guards.js";
import type App from './index.js';
import { stars, hasOpenreplayAttribute } from '../utils.js';
import { isElementNode } from './guards.js';
export interface Options {
obscureTextEmails: boolean;
@ -13,36 +13,39 @@ export default class Sanitizer {
private readonly options: Options;
constructor(private readonly app: App, options: Partial<Options>) {
this.options = Object.assign({
obscureTextEmails: true,
obscureTextNumbers: false,
}, options);
this.options = Object.assign(
{
obscureTextEmails: true,
obscureTextNumbers: false,
},
options,
);
}
handleNode(id: number, parentID: number, node: Node) {
if (
this.masked.has(parentID) ||
(isElementNode(node) &&
hasOpenreplayAttribute(node, 'masked'))
) {
this.masked.add(id);
}
if (
this.maskedContainers.has(parentID) ||
(isElementNode(node) &&
hasOpenreplayAttribute(node, 'htmlmasked'))
) {
this.maskedContainers.add(id);
}
this.masked.has(parentID) ||
(isElementNode(node) && hasOpenreplayAttribute(node, 'masked'))
) {
this.masked.add(id);
}
if (
this.maskedContainers.has(parentID) ||
(isElementNode(node) && hasOpenreplayAttribute(node, 'htmlmasked'))
) {
this.maskedContainers.add(id);
}
}
sanitize(id: number, data: string): string {
if (this.masked.has(id)) {
// TODO: is it the best place to put trim() ? Might trimmed spaces be considered in layout in certain cases?
return data.trim().replace(
/[^\f\n\r\t\v\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]/g,
'█',
);
return data
.trim()
.replace(
/[^\f\n\r\t\v\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]/g,
'█',
);
}
if (this.options.obscureTextNumbers) {
data = data.replace(/\d/g, '0');
@ -50,11 +53,10 @@ export default class Sanitizer {
if (this.options.obscureTextEmails) {
data = data.replace(
/([^\s]+)@([^\s]+)\.([^\s]+)/g,
(...f: Array<string>) =>
stars(f[1]) + '@' + stars(f[2]) + '.' + stars(f[3]),
(...f: Array<string>) => stars(f[1]) + '@' + stars(f[2]) + '.' + stars(f[3]),
);
}
return data
return data;
}
isMasked(id: number): boolean {
@ -65,15 +67,15 @@ export default class Sanitizer {
}
getInnerTextSecure(el: HTMLElement): string {
const id = this.app.nodes.getID(el)
if (!id) { return '' }
return this.sanitize(id, el.innerText)
const id = this.app.nodes.getID(el);
if (!id) {
return '';
}
return this.sanitize(id, el.innerText);
}
clear(): void {
this.masked.clear();
this.maskedContainers.clear();
}
}

View file

@ -1,54 +1,52 @@
import { UserID, UserAnonymousID, Metadata } from "../../common/messages.js";
import { UserID, UserAnonymousID, Metadata } from '../../common/messages.js';
interface SessionInfo {
sessionID: string | null,
metadata: Record<string, string>,
userID: string | null,
sessionID: string | null;
metadata: Record<string, string>;
userID: string | null;
}
type OnUpdateCallback = (i: Partial<SessionInfo>) => void
type OnUpdateCallback = (i: Partial<SessionInfo>) => void;
export default class Session {
private metadata: Record<string, string> = {}
private userID: string | null = null
private sessionID: string | null = null
private callbacks: OnUpdateCallback[] = []
private metadata: Record<string, string> = {};
private userID: string | null = null;
private sessionID: string | null = null;
private readonly callbacks: OnUpdateCallback[] = [];
attachUpdateCallback(cb: OnUpdateCallback) {
this.callbacks.push(cb)
this.callbacks.push(cb);
}
private handleUpdate(newInfo: Partial<SessionInfo>) {
if (newInfo.userID == null) {
delete newInfo.userID
delete newInfo.userID;
}
if (newInfo.sessionID == null) {
delete newInfo.sessionID
delete newInfo.sessionID;
}
this.callbacks.forEach(cb => cb(newInfo))
this.callbacks.forEach((cb) => cb(newInfo));
}
update(newInfo: Partial<SessionInfo>) {
if (newInfo.userID !== undefined) { // TODO clear nullable/undefinable types
this.userID = newInfo.userID
if (newInfo.userID !== undefined) {
// TODO clear nullable/undefinable types
this.userID = newInfo.userID;
}
if (newInfo.metadata !== undefined) {
Object.entries(newInfo.metadata).forEach(([k,v]) => this.metadata[k] = v)
Object.entries(newInfo.metadata).forEach(([k, v]) => (this.metadata[k] = v));
}
if (newInfo.sessionID !== undefined) {
this.sessionID = newInfo.sessionID
this.sessionID = newInfo.sessionID;
}
this.handleUpdate(newInfo)
this.handleUpdate(newInfo);
}
setMetadata(key: string, value: string) {
this.metadata[key] = value
this.handleUpdate({ metadata: { [key]: value } })
this.metadata[key] = value;
this.handleUpdate({ metadata: { [key]: value } });
}
setUserID(userID: string) {
this.userID = userID
this.handleUpdate({ userID })
this.userID = userID;
this.handleUpdate({ userID });
}
getInfo(): SessionInfo {
@ -56,12 +54,12 @@ export default class Session {
sessionID: this.sessionID,
metadata: this.metadata,
userID: this.userID,
}
};
}
reset(): void {
this.metadata = {}
this.userID = null
this.sessionID = null
this.metadata = {};
this.userID = null;
this.sessionID = null;
}
}
}

View file

@ -1,4 +1,4 @@
import App from "./index.js";
import App from './index.js';
type Callback = () => void;
function wrap(callback: Callback, n: number): Callback {

View file

@ -1,30 +1,39 @@
import App, { DEFAULT_INGEST_POINT } from "./app/index.js";
import App, { DEFAULT_INGEST_POINT } from './app/index.js';
export { default as App } from './app/index.js';
import { UserID, UserAnonymousID, Metadata, RawCustomEvent, CustomIssue } from "../common/messages.js";
import * as _Messages from "../common/messages.js";
import {
UserID,
UserAnonymousID,
Metadata,
RawCustomEvent,
CustomIssue,
} from '../common/messages.js';
import * as _Messages from '../common/messages.js';
export const Messages = _Messages;
import Connection from "./modules/connection.js";
import Console from "./modules/console.js";
import Exception, { getExceptionMessageFromEvent, getExceptionMessage } from "./modules/exception.js";
import Img from "./modules/img.js";
import Input from "./modules/input.js";
import Mouse from "./modules/mouse.js";
import Timing from "./modules/timing.js";
import Performance from "./modules/performance.js";
import Scroll from "./modules/scroll.js";
import Viewport from "./modules/viewport.js";
import CSSRules from "./modules/cssrules.js";
import { IN_BROWSER, deprecationWarn, DOCS_HOST } from "./utils.js";
import Connection from './modules/connection.js';
import Console from './modules/console.js';
import Exception, {
getExceptionMessageFromEvent,
getExceptionMessage,
} from './modules/exception.js';
import Img from './modules/img.js';
import Input from './modules/input.js';
import Mouse from './modules/mouse.js';
import Timing from './modules/timing.js';
import Performance from './modules/performance.js';
import Scroll from './modules/scroll.js';
import Viewport from './modules/viewport.js';
import CSSRules from './modules/cssrules.js';
import { IN_BROWSER, deprecationWarn, DOCS_HOST } from './utils.js';
import type { Options as AppOptions } from "./app/index.js";
import type { Options as ConsoleOptions } from "./modules/console.js";
import type { Options as ExceptionOptions } from "./modules/exception.js";
import type { Options as InputOptions } from "./modules/input.js";
import type { Options as PerformanceOptions } from "./modules/performance.js";
import type { Options as TimingOptions } from "./modules/timing.js";
import type { StartOptions } from './app/index.js'
import type { Options as AppOptions } from './app/index.js';
import type { Options as ConsoleOptions } from './modules/console.js';
import type { Options as ExceptionOptions } from './modules/exception.js';
import type { Options as InputOptions } from './modules/input.js';
import type { Options as PerformanceOptions } from './modules/performance.js';
import type { Options as TimingOptions } from './modules/timing.js';
import type { StartOptions } from './app/index.js';
//TODO: unique options init
import type { StartPromiseReturn } from './app/index.js';
@ -44,25 +53,32 @@ const DOCS_SETUP = '/installation/setup-or';
function processOptions(obj: any): obj is Options {
if (obj == null) {
console.error(`OpenReplay: invalid options argument type. Please, check documentation on ${DOCS_HOST}${DOCS_SETUP}`);
console.error(
`OpenReplay: invalid options argument type. Please, check documentation on ${DOCS_HOST}${DOCS_SETUP}`,
);
return false;
}
if (typeof obj.projectKey !== 'string') {
if (typeof obj.projectKey !== 'number') {
if (typeof obj.projectID !== 'number') { // Back compatability
console.error(`OpenReplay: projectKey is missing or wrong type (string is expected). Please, check ${DOCS_HOST}${DOCS_SETUP} for more information.`)
return false
if (typeof obj.projectID !== 'number') {
// Back compatability
console.error(
`OpenReplay: projectKey is missing or wrong type (string is expected). Please, check ${DOCS_HOST}${DOCS_SETUP} for more information.`,
);
return false;
} else {
obj.projectKey = obj.projectID.toString();
deprecationWarn("`projectID` option", "`projectKey` option", DOCS_SETUP)
deprecationWarn('`projectID` option', '`projectKey` option', DOCS_SETUP);
}
} else {
console.warn("OpenReplay: projectKey is expected to have a string type.")
obj.projectKey = obj.projectKey.toString()
console.warn('OpenReplay: projectKey is expected to have a string type.');
obj.projectKey = obj.projectKey.toString();
}
}
if (typeof obj.sessionToken !== 'string' && obj.sessionToken != null) {
console.warn(`OpenReplay: invalid options argument type. Please, check documentation on ${DOCS_HOST}${DOCS_SETUP}`)
console.warn(
`OpenReplay: invalid options argument type. Please, check documentation on ${DOCS_HOST}${DOCS_SETUP}`,
);
}
return true;
}
@ -74,18 +90,22 @@ export default class API {
return;
}
if ((window as any).__OPENREPLAY__) {
console.error("OpenReplay: one tracker instance has been initialised already")
return
}
if (!options.__DISABLE_SECURE_MODE && location.protocol !== 'https:') {
console.error("OpenReplay: Your website must be publicly accessible and running on SSL in order for OpenReplay to properly capture and replay the user session. You can disable this check by setting `__DISABLE_SECURE_MODE` option to `true` if you are testing in localhost. Keep in mind, that asset files on a local machine are not available to the outside world. This might affect tracking if you use css files.")
console.error('OpenReplay: one tracker instance has been initialised already');
return;
}
const doNotTrack = options.respectDoNotTrack &&
(navigator.doNotTrack == '1'
if (!options.__DISABLE_SECURE_MODE && location.protocol !== 'https:') {
console.error(
'OpenReplay: Your website must be publicly accessible and running on SSL in order for OpenReplay to properly capture and replay the user session. You can disable this check by setting `__DISABLE_SECURE_MODE` option to `true` if you are testing in localhost. Keep in mind, that asset files on a local machine are not available to the outside world. This might affect tracking if you use css files.',
);
return;
}
const doNotTrack =
options.respectDoNotTrack &&
(navigator.doNotTrack == '1' ||
// @ts-ignore
|| window.doNotTrack == '1');
const app = this.app = doNotTrack ||
window.doNotTrack == '1');
const app = (this.app =
doNotTrack ||
!('Map' in window) ||
!('Set' in window) ||
!('MutationObserver' in window) ||
@ -95,7 +115,7 @@ export default class API {
!('Blob' in window) ||
!('Worker' in window)
? null
: new App(options.projectKey, options.sessionToken, options);
: new App(options.projectKey, options.sessionToken, options));
if (app !== null) {
Viewport(app);
CSSRules(app);
@ -114,29 +134,33 @@ export default class API {
const wOpen = window.open;
app.attachStartCallback(() => {
// @ts-ignore ?
window.open = function(...args) {
app.resetNextPageSession(true)
wOpen.call(window, ...args)
app.resetNextPageSession(false)
}
})
window.open = function (...args) {
app.resetNextPageSession(true);
wOpen.call(window, ...args);
app.resetNextPageSession(false);
};
});
app.attachStopCallback(() => {
window.open = wOpen;
})
});
}
} else {
console.log("OpenReplay: browser doesn't support API required for tracking or doNotTrack is set to 1.")
console.log(
"OpenReplay: browser doesn't support API required for tracking or doNotTrack is set to 1.",
);
const req = new XMLHttpRequest();
const orig = options.ingestPoint || DEFAULT_INGEST_POINT;
req.open("POST", orig + "/v1/web/not-started");
req.open('POST', orig + '/v1/web/not-started');
// no-cors issue only with text/plain or not-set Content-Type
// req.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
req.send(JSON.stringify({
trackerVersion: 'TRACKER_VERSION',
projectKey: options.projectKey,
doNotTrack,
// TODO: add precise reason (an exact API missing)
}));
req.send(
JSON.stringify({
trackerVersion: 'TRACKER_VERSION',
projectKey: options.projectKey,
doNotTrack,
// TODO: add precise reason (an exact API missing)
}),
);
}
}
@ -151,10 +175,12 @@ export default class API {
return this.app.active();
}
start(startOpts?: Partial<StartOptions>) : Promise<StartPromiseReturn> {
start(startOpts?: Partial<StartOptions>): Promise<StartPromiseReturn> {
if (!IN_BROWSER) {
console.error(`OpenReplay: you are trying to start Tracker on a node.js environment. If you want to use OpenReplay with SSR, please, use componentDidMount or useEffect API for placing the \`tracker.start()\` line. Check documentation on ${DOCS_HOST}${DOCS_SETUP}`)
return Promise.reject("Trying to start not in browser.");
console.error(
`OpenReplay: you are trying to start Tracker on a node.js environment. If you want to use OpenReplay with SSR, please, use componentDidMount or useEffect API for placing the \`tracker.start()\` line. Check documentation on ${DOCS_HOST}${DOCS_SETUP}`,
);
return Promise.reject('Trying to start not in browser.');
}
if (this.app === null) {
return Promise.reject("Browser doesn't support required api, or doNotTrack is active.");
@ -182,7 +208,7 @@ export default class API {
return this.app.getSessionID();
}
sessionID(): string | null | undefined {
deprecationWarn("'sessionID' method", "'getSessionID' method", "/");
deprecationWarn("'sessionID' method", "'getSessionID' method", '/');
return this.getSessionID();
}
@ -192,7 +218,7 @@ export default class API {
}
}
userID(id: string): void {
deprecationWarn("'userID' method", "'setUserID' method", "/");
deprecationWarn("'userID' method", "'setUserID' method", '/');
this.setUserID(id);
}
@ -202,25 +228,21 @@ export default class API {
}
}
userAnonymousID(id: string): void {
deprecationWarn("'userAnonymousID' method", "'setUserAnonymousID' method", "/")
deprecationWarn("'userAnonymousID' method", "'setUserAnonymousID' method", '/');
this.setUserAnonymousID(id);
}
setMetadata(key: string, value: string): void {
if (
typeof key === 'string' &&
typeof value === 'string' &&
this.app !== null
) {
if (typeof key === 'string' && typeof value === 'string' && this.app !== null) {
this.app.session.setMetadata(key, value);
}
}
metadata(key: string, value: string): void {
deprecationWarn("'metadata' method", "'setMetadata' method", "/")
deprecationWarn("'metadata' method", "'setMetadata' method", '/');
this.setMetadata(key, value);
}
event(key: string, payload: any, issue: boolean = false): void {
event(key: string, payload: any, issue = false): void {
if (typeof key === 'string' && this.app !== null) {
if (issue) {
return this.issue(key, payload);
@ -247,10 +269,13 @@ export default class API {
}
handleError = (e: Error | ErrorEvent | PromiseRejectionEvent) => {
if (this.app === null) { return; }
if (this.app === null) {
return;
}
if (e instanceof Error) {
this.app.send(getExceptionMessage(e, []));
} else if (e instanceof ErrorEvent ||
} else if (
e instanceof ErrorEvent ||
('PromiseRejectionEvent' in window && e instanceof PromiseRejectionEvent)
) {
const msg = getExceptionMessageFromEvent(e);
@ -258,5 +283,5 @@ export default class API {
this.app.send(msg);
}
}
}
};
}

View file

@ -1,13 +1,13 @@
import App from "../app/index.js";
import { ConnectionInformation } from "../../common/messages.js";
import App from '../app/index.js';
import { ConnectionInformation } from '../../common/messages.js';
export default function(app: App): void {
export default function (app: App): void {
const connection:
| {
downlink: number;
type?: string;
addEventListener: (type: 'change', cb: () => void) => void;
}
downlink: number;
type?: string;
addEventListener: (type: 'change', cb: () => void) => void;
}
| undefined =
(navigator as any).connection ||
(navigator as any).mozConnection ||

View file

@ -1,7 +1,7 @@
import type App from "../app/index.js";
import { hasTag } from "../app/guards.js";
import { IN_BROWSER } from "../utils.js";
import { ConsoleLog } from "../../common/messages.js";
import type App from '../app/index.js';
import { hasTag } from '../app/guards.js';
import { IN_BROWSER } from '../utils.js';
import { ConsoleLog } from '../../common/messages.js';
const printError: (e: Error) => string =
IN_BROWSER && 'InstallTrigger' in window // detect Firefox
@ -104,10 +104,7 @@ export default function (app: App, opts: Partial<Options>): void {
},
opts,
);
if (
!Array.isArray(options.consoleMethods) ||
options.consoleMethods.length === 0
) {
if (!Array.isArray(options.consoleMethods) || options.consoleMethods.length === 0) {
return;
}
@ -139,18 +136,21 @@ export default function (app: App, opts: Partial<Options>): void {
});
patchConsole(window.console);
app.nodes.attachNodeCallback(app.safe(node => {
if (hasTag(node, "IFRAME")) { // TODO: newContextCallback
let context = node.contentWindow
if (context) {
patchConsole((context as (Window & typeof globalThis)).console)
}
app.attachEventListener(node, "load", () => {
if (node.contentWindow !== context) {
context = node.contentWindow
patchConsole((context as (Window & typeof globalThis)).console)
app.nodes.attachNodeCallback(
app.safe((node) => {
if (hasTag(node, 'IFRAME')) {
// TODO: newContextCallback
let context = node.contentWindow;
if (context) {
patchConsole((context as Window & typeof globalThis).console);
}
})
}
}))
app.attachEventListener(node, 'load', () => {
if (node.contentWindow !== context) {
context = node.contentWindow;
patchConsole((context as Window & typeof globalThis).console);
}
});
}
}),
);
}

View file

@ -1,57 +1,53 @@
import type App from "../app/index.js";
import { CSSInsertRuleURLBased, CSSDeleteRule, TechnicalInfo } from "../../common/messages.js";
import { hasTag } from "../app/guards.js";
import type App from '../app/index.js';
import { CSSInsertRuleURLBased, CSSDeleteRule, TechnicalInfo } from '../../common/messages.js';
import { hasTag } from '../app/guards.js';
export default function(app: App | null) {
export default function (app: App | null) {
if (app === null) {
return;
}
if (!window.CSSStyleSheet) {
app.send(new TechnicalInfo("no_stylesheet_prototype_in_window", ""))
app.send(new TechnicalInfo('no_stylesheet_prototype_in_window', ''));
return;
}
const processOperation = app.safe(
(stylesheet: CSSStyleSheet, index: number, rule?: string) => {
const sendMessage = typeof rule === 'string'
? (nodeID: number) => app.send(new CSSInsertRuleURLBased(nodeID, rule, index, app.getBaseHref()))
const processOperation = app.safe((stylesheet: CSSStyleSheet, index: number, rule?: string) => {
const sendMessage =
typeof rule === 'string'
? (nodeID: number) =>
app.send(new CSSInsertRuleURLBased(nodeID, rule, index, app.getBaseHref()))
: (nodeID: number) => app.send(new CSSDeleteRule(nodeID, index));
// TODO: Extend messages to maintain nested rules (CSSGroupingRule prototype, as well as CSSKeyframesRule)
if (stylesheet.ownerNode == null) {
throw new Error("Owner Node not found");
}
const nodeID = app.nodes.getID(stylesheet.ownerNode);
if (nodeID !== undefined) {
sendMessage(nodeID);
} // else error?
// TODO: Extend messages to maintain nested rules (CSSGroupingRule prototype, as well as CSSKeyframesRule)
if (stylesheet.ownerNode == null) {
throw new Error('Owner Node not found');
}
)
const nodeID = app.nodes.getID(stylesheet.ownerNode);
if (nodeID !== undefined) {
sendMessage(nodeID);
} // else error?
});
const { insertRule, deleteRule } = CSSStyleSheet.prototype;
CSSStyleSheet.prototype.insertRule = function(
rule: string,
index: number = 0,
) {
CSSStyleSheet.prototype.insertRule = function (rule: string, index = 0) {
processOperation(this, index, rule);
return insertRule.call(this, rule, index);
};
CSSStyleSheet.prototype.deleteRule = function(index: number) {
CSSStyleSheet.prototype.deleteRule = function (index: number) {
processOperation(this, index);
return deleteRule.call(this, index);
};
app.nodes.attachNodeCallback((node: Node): void => {
if (!hasTag(node, "STYLE") || !node.sheet) {
if (!hasTag(node, 'STYLE') || !node.sheet) {
return;
}
if (node.textContent !== null && node.textContent.trim().length > 0) {
return; // Only fully virtual sheets maintained so far
}
}
const rules = node.sheet.cssRules;
for (let i = 0; i < rules.length; i++) {
processOperation(node.sheet, i, rules[i].cssText)
processOperation(node.sheet, i, rules[i].cssText);
}
});
}

View file

@ -1,6 +1,6 @@
import type App from "../app/index.js";
import type Message from "../../common/messages.js";
import { JSException } from "../../common/messages.js";
import type App from '../app/index.js';
import type Message from '../../common/messages.js';
import { JSException } from '../../common/messages.js';
import ErrorStackParser from 'error-stack-parser';
export interface Options {
@ -8,53 +8,56 @@ export interface Options {
}
interface StackFrame {
columnNumber?: number,
lineNumber?: number,
fileName?: string,
functionName?: string,
source?: string,
columnNumber?: number;
lineNumber?: number;
fileName?: string;
functionName?: string;
source?: string;
}
function getDefaultStack(e: ErrorEvent): Array<StackFrame> {
return [{
columnNumber: e.colno,
lineNumber: e.lineno,
fileName: e.filename,
functionName: "",
source: "",
}];
return [
{
columnNumber: e.colno,
lineNumber: e.lineno,
fileName: e.filename,
functionName: '',
source: '',
},
];
}
export function getExceptionMessage(error: Error, fallbackStack: Array<StackFrame>): Message {
let stack = fallbackStack;
try {
stack = ErrorStackParser.parse(error);
} catch (e) {
}
} catch (e) {}
return new JSException(error.name, error.message, JSON.stringify(stack));
}
export function getExceptionMessageFromEvent(e: ErrorEvent | PromiseRejectionEvent): Message | null {
export function getExceptionMessageFromEvent(
e: ErrorEvent | PromiseRejectionEvent,
): Message | null {
if (e instanceof ErrorEvent) {
if (e.error instanceof Error) {
return getExceptionMessage(e.error, getDefaultStack(e))
return getExceptionMessage(e.error, getDefaultStack(e));
} else {
let [name, message] = e.message.split(':');
if (!message) {
name = 'Error';
message = e.message
message = e.message;
}
return new JSException(name, message, JSON.stringify(getDefaultStack(e)));
}
} else if ('PromiseRejectionEvent' in window && e instanceof PromiseRejectionEvent) {
if (e.reason instanceof Error) {
return getExceptionMessage(e.reason, [])
return getExceptionMessage(e.reason, []);
} else {
let message: string;
try {
message = JSON.stringify(e.reason)
} catch(_) {
message = String(e.reason)
message = JSON.stringify(e.reason);
} catch (_) {
message = String(e.reason);
}
return new JSException('Unhandled Promise Rejection', message, '[]');
}
@ -62,7 +65,6 @@ export function getExceptionMessageFromEvent(e: ErrorEvent | PromiseRejectionEve
return null;
}
export default function (app: App, opts: Partial<Options>): void {
const options: Options = Object.assign(
{
@ -76,17 +78,11 @@ export default function (app: App, opts: Partial<Options>): void {
if (msg != null) {
app.send(msg);
}
}
};
app.attachEventListener(
window,
'unhandledrejection',
(e: PromiseRejectionEvent): void => handler(e),
);
app.attachEventListener(
window,
'error',
(e: ErrorEvent): void => handler(e),
app.attachEventListener(window, 'unhandledrejection', (e: PromiseRejectionEvent): void =>
handler(e),
);
app.attachEventListener(window, 'error', (e: ErrorEvent): void => handler(e));
}
}

View file

@ -1,34 +1,38 @@
import type App from "../app/index.js";
import { timestamp, isURL } from "../utils.js";
import { ResourceTiming, SetNodeAttributeURLBased, SetNodeAttribute } from "../../common/messages.js";
import { hasTag } from "../app/guards.js";
import type App from '../app/index.js';
import { timestamp, isURL } from '../utils.js';
import {
ResourceTiming,
SetNodeAttributeURLBased,
SetNodeAttribute,
} from '../../common/messages.js';
import { hasTag } from '../app/guards.js';
function resolveURL(url: string, location: Location = document.location) {
url = url.trim()
if (url.startsWith('/')) {
return location.origin + url
url = url.trim();
if (url.startsWith('/')) {
return location.origin + url;
} else if (
url.startsWith('http://') ||
url.startsWith('https://') ||
url.startsWith('data:') // any other possible value here?
){
return url
) {
return url;
} else {
return location.origin + location.pathname + url
return location.origin + location.pathname + url;
}
}
const PLACEHOLDER_SRC = "https://static.openreplay.com/tracker/placeholder.jpeg";
const PLACEHOLDER_SRC = 'https://static.openreplay.com/tracker/placeholder.jpeg';
export default function (app: App): void {
function sendPlaceholder(id: number, node: HTMLImageElement): void {
app.send(new SetNodeAttribute(id, "src", PLACEHOLDER_SRC))
app.send(new SetNodeAttribute(id, 'src', PLACEHOLDER_SRC));
const { width, height } = node.getBoundingClientRect();
if (!node.hasAttribute("width")){
app.send(new SetNodeAttribute(id, "width", String(width)))
if (!node.hasAttribute('width')) {
app.send(new SetNodeAttribute(id, 'width', String(width)));
}
if (!node.hasAttribute("height")){
app.send(new SetNodeAttribute(id, "height", String(height)))
if (!node.hasAttribute('height')) {
app.send(new SetNodeAttribute(id, 'height', String(height)));
}
}
@ -41,35 +45,38 @@ export default function (app: App): void {
if (!complete) {
return;
}
const resolvedSrc = resolveURL(src || '') // Src type is null sometimes. - is it true?
const resolvedSrc = resolveURL(src || ''); // Src type is null sometimes. - is it true?
if (naturalWidth === 0 && naturalHeight === 0) {
if (isURL(resolvedSrc)) {
app.send(new ResourceTiming(timestamp(), 0, 0, 0, 0, 0, resolvedSrc, 'img'));
}
} else if (resolvedSrc.length >= 1e5 || app.sanitizer.isMasked(id)) {
sendPlaceholder(id, this)
sendPlaceholder(id, this);
} else {
app.send(new SetNodeAttribute(id, 'src', resolvedSrc))
app.send(new SetNodeAttribute(id, 'src', resolvedSrc));
if (srcset) {
const resolvedSrcset = srcset.split(',').map(str => resolveURL(str)).join(',')
app.send(new SetNodeAttribute(id, 'srcset', resolvedSrcset))
const resolvedSrcset = srcset
.split(',')
.map((str) => resolveURL(str))
.join(',');
app.send(new SetNodeAttribute(id, 'srcset', resolvedSrcset));
}
}
});
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === "attributes") {
const target = (mutation.target as HTMLImageElement);
if (mutation.type === 'attributes') {
const target = mutation.target as HTMLImageElement;
const id = app.nodes.getID(target);
if (id === undefined) {
return;
}
if (mutation.attributeName === "src") {
if (mutation.attributeName === 'src') {
const src = target.src;
app.send(new SetNodeAttributeURLBased(id, 'src', src, app.getBaseHref()));
}
if (mutation.attributeName === "srcset") {
if (mutation.attributeName === 'srcset') {
const srcset = target.srcset;
app.send(new SetNodeAttribute(id, 'srcset', srcset));
}
@ -78,12 +85,12 @@ export default function (app: App): void {
});
app.nodes.attachNodeCallback((node: Node): void => {
if (!hasTag(node, "IMG")) {
if (!hasTag(node, 'IMG')) {
return;
}
app.nodes.attachElementListener('error', node, sendImgSrc);
app.nodes.attachElementListener('load', node, sendImgSrc);
sendImgSrc.call(node);
observer.observe(node, { attributes: true, attributeFilter: [ "src", "srcset" ] });
observer.observe(node, { attributes: true, attributeFilter: ['src', 'srcset'] });
});
}

View file

@ -1,45 +1,38 @@
import type App from "../app/index.js";
import {
normSpaces,
IN_BROWSER,
getLabelAttribute,
hasOpenreplayAttribute,
} from "../utils.js";
import { hasTag } from "../app/guards.js";
import { SetInputTarget, SetInputValue, SetInputChecked } from "../../common/messages.js";
import type App from '../app/index.js';
import { normSpaces, IN_BROWSER, getLabelAttribute, hasOpenreplayAttribute } from '../utils.js';
import { hasTag } from '../app/guards.js';
import { SetInputTarget, SetInputValue, SetInputChecked } from '../../common/messages.js';
const INPUT_TYPES = ['text', 'password', 'email', 'search', 'number', 'range', 'date']
const INPUT_TYPES = ['text', 'password', 'email', 'search', 'number', 'range', 'date'];
// TODO: take into consideration "contenteditable" attribute
type TextEditableElement = HTMLInputElement | HTMLTextAreaElement
type TextEditableElement = HTMLInputElement | HTMLTextAreaElement;
function isTextEditable(node: any): node is TextEditableElement {
if (hasTag(node, "TEXTAREA")) {
if (hasTag(node, 'TEXTAREA')) {
return true;
}
if (!hasTag(node, "INPUT")) {
if (!hasTag(node, 'INPUT')) {
return false;
}
return INPUT_TYPES.includes(node.type)
return INPUT_TYPES.includes(node.type);
}
function isCheckable(node: any): node is HTMLInputElement {
if (!hasTag(node, "INPUT")) {
if (!hasTag(node, 'INPUT')) {
return false;
}
const type = node.type;
return type === 'checkbox' || type === 'radio';
}
const labelElementFor: (
element: TextEditableElement,
) => HTMLLabelElement | undefined =
const labelElementFor: (element: TextEditableElement) => HTMLLabelElement | undefined =
IN_BROWSER && 'labels' in HTMLInputElement.prototype
? (node) => {
let p: Node | null = node;
while ((p = p.parentNode) !== null) {
if (hasTag(p, "LABEL")) {
return p
if (hasTag(p, 'LABEL')) {
return p;
}
}
const labels = node.labels;
@ -50,8 +43,8 @@ const labelElementFor: (
: (node) => {
let p: Node | null = node;
while ((p = p.parentNode) !== null) {
if (hasTag(p, "LABEL")) {
return p as HTMLLabelElement;
if (hasTag(p, 'LABEL')) {
return p;
}
}
const id = node.id;
@ -67,12 +60,13 @@ export function getInputLabel(node: TextEditableElement): string {
let label = getLabelAttribute(node);
if (label === null) {
const labelElement = labelElementFor(node);
label = (labelElement && labelElement.innerText)
|| node.placeholder
|| node.name
|| node.id
|| node.className
|| node.type
label =
(labelElement && labelElement.innerText) ||
node.placeholder ||
node.name ||
node.id ||
node.className ||
node.type;
}
return normSpaces(label).slice(0, 100);
}
@ -116,8 +110,7 @@ export default function (app: App, opts: Partial<Options>): void {
(inputMode === InputMode.Plain &&
((options.obscureInputNumbers && node.type !== 'date' && /\d\d\d\d/.test(value)) ||
(options.obscureInputDates && node.type === 'date') ||
(options.obscureInputEmails &&
(node.type === 'email' || !!~value.indexOf('@')))))
(options.obscureInputEmails && (node.type === 'email' || !!~value.indexOf('@')))))
) {
inputMode = InputMode.Obscured;
}
@ -183,11 +176,11 @@ export default function (app: App, opts: Partial<Options>): void {
return;
}
// TODO: support multiple select (?): use selectedOptions; Need send target?
if (hasTag(node, "SELECT")) {
sendInputValue(id, node)
app.attachEventListener(node, "change", () => {
sendInputValue(id, node)
})
if (hasTag(node, 'SELECT')) {
sendInputValue(id, node);
app.attachEventListener(node, 'change', () => {
sendInputValue(id, node);
});
}
if (isTextEditable(node)) {
inputValues.set(id, node.value);

View file

@ -1,5 +1,5 @@
import type App from "../app/index.js";
import { LongTask } from "../../common/messages.js";
import type App from '../app/index.js';
import { LongTask } from '../../common/messages.js';
// https://w3c.github.io/performance-timeline/#the-performanceentry-interface
interface TaskAttributionTiming extends PerformanceEntry {
@ -7,22 +7,35 @@ interface TaskAttributionTiming extends PerformanceEntry {
readonly containerSrc: string;
readonly containerId: string;
readonly containerName: string;
};
}
// https://www.w3.org/TR/longtasks/#performancelongtasktiming
interface PerformanceLongTaskTiming extends PerformanceEntry {
readonly attribution: ReadonlyArray<TaskAttributionTiming>;
};
}
export default function (app: App): void {
if (!('PerformanceObserver' in window) || !('PerformanceLongTaskTiming' in window)) {
return;
}
const contexts: string[] = [ "unknown", "self", "same-origin-ancestor", "same-origin-descendant", "same-origin", "cross-origin-ancestor", "cross-origin-descendant", "cross-origin-unreachable", "multiple-contexts" ];
const containerTypes: string[] = [ "window", "iframe", "embed", "object" ];
const contexts: string[] = [
'unknown',
'self',
'same-origin-ancestor',
'same-origin-descendant',
'same-origin',
'cross-origin-ancestor',
'cross-origin-descendant',
'cross-origin-unreachable',
'multiple-contexts',
];
const containerTypes: string[] = ['window', 'iframe', 'embed', 'object'];
function longTask(entry: PerformanceLongTaskTiming): void {
let type: string = "", src: string = "", id: string = "", name: string = "";
let type = '',
src = '',
id = '',
name = '';
const container = entry.attribution[0];
if (container != null) {
type = container.containerType;
@ -48,4 +61,4 @@ export default function (app: App): void {
list.getEntries().forEach(longTask),
);
observer.observe({ entryTypes: ['longtask'] });
}
}

View file

@ -1,44 +1,43 @@
import type App from "../app/index.js";
import { hasTag, isSVGElement } from "../app/guards.js";
import {
normSpaces,
hasOpenreplayAttribute,
getLabelAttribute,
} from "../utils.js";
import { MouseMove, MouseClick } from "../../common/messages.js";
import { getInputLabel } from "./input.js";
import type App from '../app/index.js';
import { hasTag, isSVGElement } from '../app/guards.js';
import { normSpaces, hasOpenreplayAttribute, getLabelAttribute } from '../utils.js';
import { MouseMove, MouseClick } from '../../common/messages.js';
import { getInputLabel } from './input.js';
function _getSelector(target: Element): string {
let el: Element | null = target
let selector: string | null = null
let el: Element | null = target;
let selector: string | null = null;
do {
if (el.id) {
return `#${el.id}` + (selector ? ` > ${selector}` : '')
return `#${el.id}` + (selector ? ` > ${selector}` : '');
}
selector =
el.className.split(' ')
.map(cn => cn.trim())
.filter(cn => cn !== '')
.reduce((sel, cn) => `${sel}.${cn}`, el.tagName.toLowerCase()) +
(selector ? ` > ${selector}` : '');
if (el === document.body) {
return selector
}
el = el.parentElement
} while (el !== document.body && el !== null)
return selector
el.className
.split(' ')
.map((cn) => cn.trim())
.filter((cn) => cn !== '')
.reduce((sel, cn) => `${sel}.${cn}`, el.tagName.toLowerCase()) +
(selector ? ` > ${selector}` : '');
if (el === document.body) {
return selector;
}
el = el.parentElement;
} while (el !== document.body && el !== null);
return selector;
}
function isClickable(element: Element): boolean {
const tag = element.tagName.toUpperCase()
return tag === 'BUTTON' ||
const tag = element.tagName.toUpperCase();
return (
tag === 'BUTTON' ||
tag === 'A' ||
tag === 'LI' ||
tag === 'SELECT' ||
(element as HTMLElement).onclick != null ||
element.getAttribute('role') === 'button'
//|| element.className.includes("btn")
// MBTODO: intersect addEventListener
element.getAttribute('role') === 'button'
);
//|| element.className.includes("btn")
// MBTODO: intersect addEventListener
}
//TODO: fix (typescript doesn't allow work when the guard is inside the function)
@ -73,9 +72,7 @@ function _getTarget(target: Element): Element | null {
if (tag === 'INPUT') {
return element;
}
if (isClickable(element) ||
getLabelAttribute(element) !== null
) {
if (isClickable(element) || getLabelAttribute(element) !== null) {
return element;
}
element = element.parentElement;
@ -84,22 +81,21 @@ function _getTarget(target: Element): Element | null {
}
export default function (app: App): void {
function getTargetLabel(target: Element): string {
const dl = getLabelAttribute(target);
if (dl !== null) {
return dl;
}
if (hasTag(target, "INPUT")) {
return getInputLabel(target)
if (hasTag(target, 'INPUT')) {
return getInputLabel(target);
}
if (isClickable(target)) {
let label = ''
if (target instanceof HTMLElement) {
label = app.sanitizer.getInnerTextSecure(target)
}
label = label || target.id || target.className
return normSpaces(label).slice(0, 100)
let label = '';
if (target instanceof HTMLElement) {
label = app.sanitizer.getInnerTextSecure(target);
}
label = label || target.id || target.className;
return normSpaces(label).slice(0, 100);
}
return '';
}
@ -124,22 +120,18 @@ export default function (app: App): void {
}
};
const selectorMap: {[id:number]: string} = {};
const selectorMap: { [id: number]: string } = {};
function getSelector(id: number, target: Element): string {
return selectorMap[id] = selectorMap[id] || _getSelector(target);
return (selectorMap[id] = selectorMap[id] || _getSelector(target));
}
app.attachEventListener(
document.documentElement,
'mouseover',
(e: MouseEvent): void => {
const target = getTarget(e.target);
if (target !== mouseTarget) {
mouseTarget = target;
mouseTargetTime = performance.now();
}
},
);
app.attachEventListener(document.documentElement, 'mouseover', (e: MouseEvent): void => {
const target = getTarget(e.target);
if (target !== mouseTarget) {
mouseTarget = target;
mouseTargetTime = performance.now();
}
});
app.attachEventListener(
document,
'mousemove',
@ -158,12 +150,10 @@ export default function (app: App): void {
const id = app.nodes.getID(target);
if (id !== undefined) {
sendMouseMove();
app.send(new
MouseClick(
app.send(
new MouseClick(
id,
mouseTarget === target
? Math.round(performance.now() - mouseTargetTime)
: 0,
mouseTarget === target ? Math.round(performance.now() - mouseTargetTime) : 0,
getTargetLabel(target),
getSelector(id, target),
),

View file

@ -1,20 +1,19 @@
import type App from "../app/index.js";
import { IN_BROWSER } from "../utils.js";
import { PerformanceTrack } from "../../common/messages.js";
import type App from '../app/index.js';
import { IN_BROWSER } from '../utils.js';
import { PerformanceTrack } from '../../common/messages.js';
type Perf = {
memory: {
totalJSHeapSize?: number,
usedJSHeapSize?: number,
jsHeapSizeLimit?: number,
}
}
const perf: Perf = IN_BROWSER && 'performance' in window && 'memory' in performance // works in Chrome only
? performance as any
: { memory: {} }
memory: {
totalJSHeapSize?: number;
usedJSHeapSize?: number;
jsHeapSizeLimit?: number;
};
};
const perf: Perf =
IN_BROWSER && 'performance' in window && 'memory' in performance // works in Chrome only
? (performance as any)
: { memory: {} };
export const deviceMemory = IN_BROWSER ? ((navigator as any).deviceMemory || 0) * 1024 : 0;
export const jsHeapSizeLimit = perf.memory.jsHeapSizeLimit || 0;
@ -30,7 +29,9 @@ export default function (app: App, opts: Partial<Options>): void {
},
opts,
);
if (!options.capturePerformance) { return; }
if (!options.capturePerformance) {
return;
}
let frames: number | undefined;
let ticks: number | undefined;
@ -58,8 +59,8 @@ export default function (app: App, opts: Partial<Options>): void {
if (frames === undefined || ticks === undefined) {
return;
}
app.send(new
PerformanceTrack(
app.send(
new PerformanceTrack(
frames,
ticks,
perf.memory.totalJSHeapSize || 0,

View file

@ -1,14 +1,14 @@
import type App from "../app/index.js";
import { SetViewportScroll, SetNodeScroll } from "../../common/messages.js";
import { isElementNode } from "../app/guards.js";
import type App from '../app/index.js';
import { SetViewportScroll, SetNodeScroll } from '../../common/messages.js';
import { isElementNode } from '../app/guards.js';
export default function (app: App): void {
let documentScroll = false;
const nodeScroll: Map<Element, [number, number]> = new Map();
const sendSetViewportScroll = app.safe((): void =>
app.send(new
SetViewportScroll(
app.send(
new SetViewportScroll(
window.pageXOffset ||
(document.documentElement && document.documentElement.scrollLeft) ||
(document.body && document.body.scrollLeft) ||
@ -39,7 +39,7 @@ export default function (app: App): void {
if (isStart && isElementNode(node) && node.scrollLeft + node.scrollTop > 0) {
nodeScroll.set(node, [node.scrollLeft, node.scrollTop]);
}
})
});
app.attachEventListener(window, 'scroll', (e: Event): void => {
const target = e.target;

View file

@ -1,8 +1,7 @@
import type App from "../app/index.js";
import { hasTag } from "../app/guards.js";
import { isURL } from "../utils.js";
import { ResourceTiming, PageLoadTiming, PageRenderTiming } from "../../common/messages.js";
import type App from '../app/index.js';
import { hasTag } from '../app/guards.js';
import { isURL } from '../utils.js';
import { ResourceTiming, PageLoadTiming, PageRenderTiming } from '../../common/messages.js';
// Inspired by https://github.com/WPO-Foundation/RUM-SpeedIndex/blob/master/src/rum-speedindex.js
@ -22,13 +21,11 @@ function getPaintBlocks(resources: ResourcesTimeMap): Array<PaintBlock> {
for (let i = 0; i < elements.length; i++) {
const element = elements[i];
let src = '';
if (hasTag(element, "IMG")) {
if (hasTag(element, 'IMG')) {
src = element.currentSrc || element.src;
}
if (!src) {
const backgroundImage = getComputedStyle(element).getPropertyValue(
'background-image',
);
const backgroundImage = getComputedStyle(element).getPropertyValue('background-image');
if (backgroundImage) {
const matches = styleURL.exec(backgroundImage);
if (matches !== null) {
@ -53,9 +50,7 @@ function getPaintBlocks(resources: ResourcesTimeMap): Array<PaintBlock> {
);
const right = Math.min(
rect.right,
window.innerWidth ||
(document.documentElement && document.documentElement.clientWidth) ||
0,
window.innerWidth || (document.documentElement && document.documentElement.clientWidth) || 0,
);
if (bottom <= top || right <= left) continue;
const area = (bottom - top) * (right - left);
@ -64,18 +59,14 @@ function getPaintBlocks(resources: ResourcesTimeMap): Array<PaintBlock> {
return paintBlocks;
}
function calculateSpeedIndex(
firstContentfulPaint: number,
paintBlocks: Array<PaintBlock>,
): number {
function calculateSpeedIndex(firstContentfulPaint: number, paintBlocks: Array<PaintBlock>): number {
let a =
(Math.max(
(document.documentElement && document.documentElement.clientWidth) || 0,
window.innerWidth || 0,
) *
Math.max(
(document.documentElement && document.documentElement.clientHeight) ||
0,
(document.documentElement && document.documentElement.clientHeight) || 0,
window.innerHeight || 0,
)) /
10;
@ -106,25 +97,23 @@ export default function (app: App, opts: Partial<Options>): void {
if (!('PerformanceObserver' in window)) {
options.captureResourceTimings = false;
}
if (!options.captureResourceTimings) { return } // Resources are necessary for all timings
if (!options.captureResourceTimings) {
return;
} // Resources are necessary for all timings
let resources: ResourcesTimeMap | null = {}
let resources: ResourcesTimeMap | null = {};
function resourceTiming(entry: PerformanceResourceTiming): void {
if (entry.duration < 0 || !isURL(entry.name) || app.isServiceURL(entry.name)) return;
if (resources !== null) {
resources[entry.name] = entry.startTime + entry.duration;
}
app.send(new
ResourceTiming(
app.send(
new ResourceTiming(
entry.startTime + performance.timing.navigationStart,
entry.duration,
entry.responseStart && entry.startTime
? entry.responseStart - entry.startTime
: 0,
entry.transferSize > entry.encodedBodySize
? entry.transferSize - entry.encodedBodySize
: 0,
entry.responseStart && entry.startTime ? entry.responseStart - entry.startTime : 0,
entry.transferSize > entry.encodedBodySize ? entry.transferSize - entry.encodedBodySize : 0,
entry.encodedBodySize || 0,
entry.decodedBodySize || 0,
entry.name,
@ -133,48 +122,45 @@ export default function (app: App, opts: Partial<Options>): void {
);
}
const observer: PerformanceObserver = new PerformanceObserver(
(list) => list.getEntries().forEach(resourceTiming),
)
const observer: PerformanceObserver = new PerformanceObserver((list) =>
list.getEntries().forEach(resourceTiming),
);
let prevSessionID: string | undefined
app.attachStartCallback(function({ sessionID }) {
if (sessionID !== prevSessionID) { // Send past page resources on a newly started session
performance.getEntriesByType('resource').forEach(resourceTiming)
prevSessionID = sessionID
let prevSessionID: string | undefined;
app.attachStartCallback(function ({ sessionID }) {
if (sessionID !== prevSessionID) {
// Send past page resources on a newly started session
performance.getEntriesByType('resource').forEach(resourceTiming);
prevSessionID = sessionID;
}
observer.observe({ entryTypes: ['resource'] })
})
app.attachStopCallback(function() {
observer.disconnect()
})
observer.observe({ entryTypes: ['resource'] });
});
app.attachStopCallback(function () {
observer.disconnect();
});
let firstPaint = 0,
firstContentfulPaint = 0;
if (options.capturePageLoadTimings) {
let pageLoadTimingSent: boolean = false;
let pageLoadTimingSent = false;
app.ticker.attach(() => {
if (pageLoadTimingSent) {
return;
}
if (firstPaint === 0 || firstContentfulPaint === 0) {
performance
.getEntriesByType('paint')
.forEach((entry: PerformanceEntry) => {
const { name, startTime } = entry;
switch (name) {
case 'first-paint':
firstPaint = startTime;
break;
case 'first-contentful-paint':
firstContentfulPaint = startTime;
break;
}
});
performance.getEntriesByType('paint').forEach((entry: PerformanceEntry) => {
const { name, startTime } = entry;
switch (name) {
case 'first-paint':
firstPaint = startTime;
break;
case 'first-contentful-paint':
firstContentfulPaint = startTime;
break;
}
});
}
if (performance.timing.loadEventEnd || performance.now() > 30000) {
pageLoadTimingSent = true;
@ -188,8 +174,8 @@ export default function (app: App, opts: Partial<Options>): void {
loadEventStart,
loadEventEnd,
} = performance.timing;
app.send(new
PageLoadTiming(
app.send(
new PageLoadTiming(
requestStart - navigationStart || 0,
responseStart - navigationStart || 0,
responseEnd - navigationStart || 0,
@ -211,7 +197,7 @@ export default function (app: App, opts: Partial<Options>): void {
interactiveWindowTickTime: number | null = 0,
paintBlocks: Array<PaintBlock> | null = null;
let pageRenderTimingSent: boolean = false;
let pageRenderTimingSent = false;
app.ticker.attach(() => {
if (pageRenderTimingSent) {
return;
@ -231,37 +217,28 @@ export default function (app: App, opts: Partial<Options>): void {
if (time - interactiveWindowTickTime > 50) {
interactiveWindowStartTime = time;
}
interactiveWindowTickTime =
time - interactiveWindowStartTime > 5000 ? null : time;
interactiveWindowTickTime = time - interactiveWindowStartTime > 5000 ? null : time;
}
if (
(paintBlocks !== null && interactiveWindowTickTime === null) ||
time > 30000
) {
if ((paintBlocks !== null && interactiveWindowTickTime === null) || time > 30000) {
pageRenderTimingSent = true;
resources = null;
const speedIndex =
paintBlocks === null
? 0
: calculateSpeedIndex(
firstContentfulPaint || firstPaint,
paintBlocks,
);
: calculateSpeedIndex(firstContentfulPaint || firstPaint, paintBlocks);
const timeToInteractive =
interactiveWindowTickTime === null
? Math.max(
interactiveWindowStartTime,
firstContentfulPaint,
performance.timing.domContentLoadedEventEnd -
performance.timing.navigationStart || 0,
performance.timing.domContentLoadedEventEnd - performance.timing.navigationStart ||
0,
)
: 0;
app.send(new
PageRenderTiming(
app.send(
new PageRenderTiming(
speedIndex,
firstContentfulPaint > visuallyComplete
? firstContentfulPaint
: visuallyComplete,
firstContentfulPaint > visuallyComplete ? firstContentfulPaint : visuallyComplete,
timeToInteractive,
),
);

View file

@ -1,9 +1,5 @@
import type App from "../app/index.js";
import {
SetPageLocation,
SetViewportSize,
SetPageVisibility,
} from "../../common/messages.js";
import type App from '../app/index.js';
import { SetPageLocation, SetViewportSize, SetPageVisibility } from '../../common/messages.js';
export default function (app: App): void {
let url: string, width: number, height: number;

View file

@ -13,48 +13,53 @@ export function normSpaces(str: string): string {
// isAbsoluteUrl regexp: /^([a-z][a-z\d\+\-\.]*:)?\/\//i.test(url)
export function isURL(s: string): boolean {
return s.startsWith('https://')|| s.startsWith('http://');
return s.startsWith('https://') || s.startsWith('http://');
}
export const IN_BROWSER = !(typeof window === "undefined");
export const IN_BROWSER = !(typeof window === 'undefined');
// TODO: JOIN IT WITH LOGGER somehow (use logging decorators?); Don't forget about index.js loggin when there is no logger instance.
export const DOCS_HOST = 'https://docs.openreplay.com';
const warnedFeatures: { [key: string]: boolean; } = {};
export function deprecationWarn(nameOfFeature: string, useInstead: string, docsPath: string = "/"): void {
if (warnedFeatures[ nameOfFeature ]) {
return;
}
console.warn(`OpenReplay: ${ nameOfFeature } is deprecated. ${ useInstead ? `Please, use ${ useInstead } instead.` : "" } Visit ${DOCS_HOST}${docsPath} for more information.`)
warnedFeatures[ nameOfFeature ] = true;
const warnedFeatures: { [key: string]: boolean } = {};
export function deprecationWarn(nameOfFeature: string, useInstead: string, docsPath = '/'): void {
if (warnedFeatures[nameOfFeature]) {
return;
}
console.warn(
`OpenReplay: ${nameOfFeature} is deprecated. ${
useInstead ? `Please, use ${useInstead} instead.` : ''
} Visit ${DOCS_HOST}${docsPath} for more information.`,
);
warnedFeatures[nameOfFeature] = true;
}
export function getLabelAttribute(e: Element): string | null {
let value = e.getAttribute("data-openreplay-label");
if (value !== null) {
return value;
}
value = e.getAttribute("data-asayer-label");
if (value !== null) {
deprecationWarn(`"data-asayer-label" attribute`, `"data-openreplay-label" attribute`, "/");
}
return value;
let value = e.getAttribute('data-openreplay-label');
if (value !== null) {
return value;
}
value = e.getAttribute('data-asayer-label');
if (value !== null) {
deprecationWarn('"data-asayer-label" attribute', '"data-openreplay-label" attribute', '/');
}
return value;
}
export function hasOpenreplayAttribute(e: Element, name: string): boolean {
const newName = `data-openreplay-${name}`;
if (e.hasAttribute(newName)) {
return true
}
const oldName = `data-asayer-${name}`;
if (e.hasAttribute(oldName)) {
deprecationWarn(`"${oldName}" attribute`, `"${newName}" attribute`, "/installation/sanitize-data");
return true;
}
return false;
const newName = `data-openreplay-${name}`;
if (e.hasAttribute(newName)) {
return true;
}
const oldName = `data-asayer-${name}`;
if (e.hasAttribute(oldName)) {
deprecationWarn(
`"${oldName}" attribute`,
`"${newName}" attribute`,
'/installation/sanitize-data',
);
return true;
}
return false;
}

View file

@ -1,10 +1,10 @@
type Node = {
name: string
penalty: number
level?: number
}
name: string;
penalty: number;
level?: number;
};
type Path = Node[]
type Path = Node[];
enum Limit {
All,
@ -13,27 +13,27 @@ enum Limit {
}
export type Options = {
root: Element
idName: (name: string) => boolean
className: (name: string) => boolean
tagName: (name: string) => boolean
attr: (name: string, value: string) => boolean
seedMinLength: number
optimizedMinLength: number
threshold: number
maxNumberOfTries: number
}
root: Element;
idName: (name: string) => boolean;
className: (name: string) => boolean;
tagName: (name: string) => boolean;
attr: (name: string, value: string) => boolean;
seedMinLength: number;
optimizedMinLength: number;
threshold: number;
maxNumberOfTries: number;
};
let config: Options
let config: Options;
let rootDocument: Document | Element
let rootDocument: Document | Element;
export function finder(input: Element, options?: Partial<Options>) {
if (input.nodeType !== Node.ELEMENT_NODE) {
throw new Error(`Can't generate CSS selector for non-element node type.`)
throw new Error("Can't generate CSS selector for non-element node type.");
}
if ("html" === input.tagName.toLowerCase()) {
return "html"
if ('html' === input.tagName.toLowerCase()) {
return 'html';
}
const defaults: Options = {
@ -46,356 +46,370 @@ export function finder(input: Element, options?: Partial<Options>) {
optimizedMinLength: 2,
threshold: 1000,
maxNumberOfTries: 10000,
}
};
config = {...defaults, ...options}
config = { ...defaults, ...options };
rootDocument = findRootDocument(config.root, defaults)
rootDocument = findRootDocument(config.root, defaults);
let path =
bottomUpSearch(input, Limit.All, () =>
bottomUpSearch(input, Limit.Two, () =>
bottomUpSearch(input, Limit.One)))
let path = bottomUpSearch(input, Limit.All, () =>
bottomUpSearch(input, Limit.Two, () => bottomUpSearch(input, Limit.One)),
);
if (path) {
const optimized = sort(optimize(path, input))
const optimized = sort(optimize(path, input));
if (optimized.length > 0) {
path = optimized[0]
path = optimized[0];
}
return selector(path)
return selector(path);
} else {
throw new Error(`Selector was not found.`)
throw new Error('Selector was not found.');
}
}
function findRootDocument(rootNode: Element | Document, defaults: Options) {
if (rootNode.nodeType === Node.DOCUMENT_NODE) {
return rootNode
return rootNode;
}
if (rootNode === defaults.root) {
return rootNode.ownerDocument as Document
return rootNode.ownerDocument;
}
return rootNode
return rootNode;
}
function bottomUpSearch(input: Element, limit: Limit, fallback?: () => Path | null): Path | null {
let path: Path | null = null
let stack: Node[][] = []
let current: Element | null = input
let i = 0
let path: Path | null = null;
const stack: Node[][] = [];
let current: Element | null = input;
let i = 0;
while (current && current !== config.root.parentElement) {
let level: Node[] = maybe(id(current)) || maybe(...attr(current)) || maybe(...classNames(current)) || maybe(tagName(current)) || [any()]
let level: Node[] = maybe(id(current)) ||
maybe(...attr(current)) ||
maybe(...classNames(current)) ||
maybe(tagName(current)) || [any()];
const nth = index(current)
const nth = index(current);
if (limit === Limit.All) {
if (nth) {
level = level.concat(level.filter(dispensableNth).map(node => nthChild(node, nth)))
level = level.concat(level.filter(dispensableNth).map((node) => nthChild(node, nth)));
}
} else if (limit === Limit.Two) {
level = level.slice(0, 1)
level = level.slice(0, 1);
if (nth) {
level = level.concat(level.filter(dispensableNth).map(node => nthChild(node, nth)))
level = level.concat(level.filter(dispensableNth).map((node) => nthChild(node, nth)));
}
} else if (limit === Limit.One) {
const [node] = level = level.slice(0, 1)
const [node] = (level = level.slice(0, 1));
if (nth && dispensableNth(node)) {
level = [nthChild(node, nth)]
level = [nthChild(node, nth)];
}
}
for (let node of level) {
node.level = i
for (const node of level) {
node.level = i;
}
stack.push(level)
stack.push(level);
if (stack.length >= config.seedMinLength) {
path = findUniquePath(stack, fallback)
path = findUniquePath(stack, fallback);
if (path) {
break
break;
}
}
current = current.parentElement
i++
current = current.parentElement;
i++;
}
if (!path) {
path = findUniquePath(stack, fallback)
path = findUniquePath(stack, fallback);
}
return path
return path;
}
function findUniquePath(stack: Node[][], fallback?: () => Path | null): Path | null {
const paths = sort(combinations(stack))
const paths = sort(combinations(stack));
if (paths.length > config.threshold) {
return fallback ? fallback() : null
return fallback ? fallback() : null;
}
for (let candidate of paths) {
for (const candidate of paths) {
if (unique(candidate)) {
return candidate
return candidate;
}
}
return null
return null;
}
function selector(path: Path): string {
let node = path[0]
let query = node.name
let node = path[0];
let query = node.name;
for (let i = 1; i < path.length; i++) {
const level = path[i].level || 0
const level = path[i].level || 0;
if (node.level === level - 1) {
query = `${path[i].name} > ${query}`
query = `${path[i].name} > ${query}`;
} else {
query = `${path[i].name} ${query}`
query = `${path[i].name} ${query}`;
}
node = path[i]
node = path[i];
}
return query
return query;
}
function penalty(path: Path): number {
return path.map(node => node.penalty).reduce((acc, i) => acc + i, 0)
return path.map((node) => node.penalty).reduce((acc, i) => acc + i, 0);
}
function unique(path: Path) {
switch (rootDocument.querySelectorAll(selector(path)).length) {
case 0:
throw new Error(`Can't select any node with this selector: ${selector(path)}`)
throw new Error(`Can't select any node with this selector: ${selector(path)}`);
case 1:
return true
return true;
default:
return false
return false;
}
}
function id(input: Element): Node | null {
const elementId = input.getAttribute("id")
const elementId = input.getAttribute('id');
if (elementId && config.idName(elementId)) {
return {
name: "#" + cssesc(elementId, {isIdentifier: true}),
name: '#' + cssesc(elementId, { isIdentifier: true }),
penalty: 0,
}
};
}
return null
return null;
}
function attr(input: Element): Node[] {
const attrs = Array.from(input.attributes).filter((attr) => config.attr(attr.name, attr.value))
const attrs = Array.from(input.attributes).filter((attr) => config.attr(attr.name, attr.value));
return attrs.map((attr): Node => ({
name: "[" + cssesc(attr.name, {isIdentifier: true}) + "=\"" + cssesc(attr.value) + "\"]",
penalty: 0.5
}))
return attrs.map(
(attr): Node => ({
name: '[' + cssesc(attr.name, { isIdentifier: true }) + '="' + cssesc(attr.value) + '"]',
penalty: 0.5,
}),
);
}
function classNames(input: Element): Node[] {
const names = Array.from(input.classList)
.filter(config.className)
const names = Array.from(input.classList).filter(config.className);
return names.map((name): Node => ({
name: "." + cssesc(name, {isIdentifier: true}),
penalty: 1
}))
return names.map(
(name): Node => ({
name: '.' + cssesc(name, { isIdentifier: true }),
penalty: 1,
}),
);
}
function tagName(input: Element): Node | null {
const name = input.tagName.toLowerCase()
const name = input.tagName.toLowerCase();
if (config.tagName(name)) {
return {
name,
penalty: 2
}
penalty: 2,
};
}
return null
return null;
}
function any(): Node {
return {
name: "*",
penalty: 3
}
name: '*',
penalty: 3,
};
}
function index(input: Element): number | null {
const parent = input.parentNode
const parent = input.parentNode;
if (!parent) {
return null
return null;
}
let child = parent.firstChild
let child = parent.firstChild;
if (!child) {
return null
return null;
}
let i = 0
let i = 0;
while (child) {
if (child.nodeType === Node.ELEMENT_NODE) {
i++
i++;
}
if (child === input) {
break
break;
}
child = child.nextSibling
child = child.nextSibling;
}
return i
return i;
}
function nthChild(node: Node, i: number): Node {
return {
name: node.name + `:nth-child(${i})`,
penalty: node.penalty + 1
}
penalty: node.penalty + 1,
};
}
function dispensableNth(node: Node) {
return node.name !== "html" && !node.name.startsWith("#")
return node.name !== 'html' && !node.name.startsWith('#');
}
function maybe(...level: (Node | null)[]): Node[] | null {
const list = level.filter(notEmpty)
const list = level.filter(notEmpty);
if (list.length > 0) {
return list
return list;
}
return null
return null;
}
function notEmpty<T>(value: T | null | undefined): value is T {
return value !== null && value !== undefined
return value !== null && value !== undefined;
}
function combinations(stack: Node[][], path: Node[] = []): Node[][] {
const paths: Node[][] = []
const paths: Node[][] = [];
if (stack.length > 0) {
for (let node of stack[0]) {
paths.push(...combinations(stack.slice(1, stack.length), path.concat(node)))
for (const node of stack[0]) {
paths.push(...combinations(stack.slice(1, stack.length), path.concat(node)));
}
} else {
paths.push(path)
paths.push(path);
}
return paths
return paths;
}
function sort(paths: Iterable<Path>): Path[] {
return Array.from(paths).sort((a, b) => penalty(a) - penalty(b))
return Array.from(paths).sort((a, b) => penalty(a) - penalty(b));
}
type Scope = {
counter: number
visited: Map<string, boolean>
}
counter: number;
visited: Map<string, boolean>;
};
function optimize(path: Path, input: Element, scope: Scope = {
counter: 0,
visited: new Map<string, boolean>()
}): Node[][] {
const paths: Node[][] = []
function optimize(
path: Path,
input: Element,
scope: Scope = {
counter: 0,
visited: new Map<string, boolean>(),
},
): Node[][] {
const paths: Node[][] = [];
if (path.length > 2 && path.length > config.optimizedMinLength) {
for (let i = 1; i < path.length - 1; i++) {
if (scope.counter > config.maxNumberOfTries) {
return paths // Okay At least I tried!
return paths; // Okay At least I tried!
}
scope.counter += 1
const newPath = [...path]
newPath.splice(i, 1)
const newPathKey = selector(newPath)
scope.counter += 1;
const newPath = [...path];
newPath.splice(i, 1);
const newPathKey = selector(newPath);
if (scope.visited.has(newPathKey)) {
return paths
return paths;
}
if (unique(newPath) && same(newPath, input)) {
paths.push(newPath)
scope.visited.set(newPathKey, true)
paths.push(...optimize(newPath, input, scope))
paths.push(newPath);
scope.visited.set(newPathKey, true);
paths.push(...optimize(newPath, input, scope));
}
}
}
return paths
return paths;
}
function same(path: Path, input: Element) {
return rootDocument.querySelector(selector(path)) === input
return rootDocument.querySelector(selector(path)) === input;
}
const regexAnySingleEscape = /[ -,\.\/:-@\[-\^`\{-~]/
const regexSingleEscape = /[ -,\.\/:-@\[\]\^`\{-~]/
const regexExcessiveSpaces = /(^|\\+)?(\\[A-F0-9]{1,6})\x20(?![a-fA-F0-9\x20])/g
const regexAnySingleEscape = /[ -,\.\/:-@\[-\^`\{-~]/;
const regexSingleEscape = /[ -,\.\/:-@\[\]\^`\{-~]/;
const regexExcessiveSpaces = /(^|\\+)?(\\[A-F0-9]{1,6})\x20(?![a-fA-F0-9\x20])/g;
const defaultOptions = {
"escapeEverything": false,
"isIdentifier": false,
"quotes": "single",
"wrap": false
}
escapeEverything: false,
isIdentifier: false,
quotes: 'single',
wrap: false,
};
function cssesc(string: string, opt: Partial<typeof defaultOptions> = {}) {
const options = {...defaultOptions, ...opt}
if (options.quotes != "single" && options.quotes != "double") {
options.quotes = "single"
const options = { ...defaultOptions, ...opt };
if (options.quotes != 'single' && options.quotes != 'double') {
options.quotes = 'single';
}
const quote = options.quotes == "double" ? "\"" : "'"
const isIdentifier = options.isIdentifier
const quote = options.quotes == 'double' ? '"' : "'";
const isIdentifier = options.isIdentifier;
const firstChar = string.charAt(0)
let output = ""
let counter = 0
const length = string.length
const firstChar = string.charAt(0);
let output = '';
let counter = 0;
const length = string.length;
while (counter < length) {
const character = string.charAt(counter++)
let codePoint = character.charCodeAt(0)
let value: string | undefined = void 0
const character = string.charAt(counter++);
let codePoint = character.charCodeAt(0);
let value: string | undefined = void 0;
// If its not a printable ASCII character…
if (codePoint < 0x20 || codePoint > 0x7E) {
if (codePoint >= 0xD800 && codePoint <= 0xDBFF && counter < length) {
if (codePoint < 0x20 || codePoint > 0x7e) {
if (codePoint >= 0xd800 && codePoint <= 0xdbff && counter < length) {
// Its a high surrogate, and there is a next character.
const extra = string.charCodeAt(counter++)
if ((extra & 0xFC00) == 0xDC00) {
const extra = string.charCodeAt(counter++);
if ((extra & 0xfc00) == 0xdc00) {
// next character is low surrogate
codePoint = ((codePoint & 0x3FF) << 10) + (extra & 0x3FF) + 0x10000
codePoint = ((codePoint & 0x3ff) << 10) + (extra & 0x3ff) + 0x10000;
} else {
// Its an unmatched surrogate; only append this code unit, in case
// the next code unit is the high surrogate of a surrogate pair.
counter--
counter--;
}
}
value = "\\" + codePoint.toString(16).toUpperCase() + " "
value = '\\' + codePoint.toString(16).toUpperCase() + ' ';
} else {
if (options.escapeEverything) {
if (regexAnySingleEscape.test(character)) {
value = "\\" + character
value = '\\' + character;
} else {
value = "\\" + codePoint.toString(16).toUpperCase() + " "
value = '\\' + codePoint.toString(16).toUpperCase() + ' ';
}
} else if (/[\t\n\f\r\x0B]/.test(character)) {
value = "\\" + codePoint.toString(16).toUpperCase() + " "
} else if (character == "\\" || !isIdentifier && (character == "\"" && quote == character || character == "'" && quote == character) || isIdentifier && regexSingleEscape.test(character)) {
value = "\\" + character
value = '\\' + codePoint.toString(16).toUpperCase() + ' ';
} else if (
character == '\\' ||
(!isIdentifier &&
((character == '"' && quote == character) || (character == "'" && quote == character))) ||
(isIdentifier && regexSingleEscape.test(character))
) {
value = '\\' + character;
} else {
value = character
value = character;
}
}
output += value
output += value;
}
if (isIdentifier) {
if (/^-[-\d]/.test(output)) {
output = "\\-" + output.slice(1)
output = '\\-' + output.slice(1);
} else if (/\d/.test(firstChar)) {
output = "\\3" + firstChar + " " + output.slice(1)
output = '\\3' + firstChar + ' ' + output.slice(1);
}
}
@ -405,14 +419,14 @@ function cssesc(string: string, opt: Partial<typeof defaultOptions> = {}) {
output = output.replace(regexExcessiveSpaces, function ($0, $1, $2) {
if ($1 && $1.length % 2) {
// Its not safe to remove the space, so dont.
return $0
return $0;
}
// Strip the space.
return ($1 || "") + $2
})
return ($1 || '') + $2;
});
if (!isIdentifier && options.wrap) {
return quote + output + quote
return quote + output + quote;
}
return output
return output;
}

View file

@ -1,76 +1,74 @@
import type Message from "../common/messages.js";
import PrimitiveWriter from "./PrimitiveWriter.js";
import {
BatchMeta,
Timestamp,
} from "../common/messages.js";
import type Message from '../common/messages.js';
import PrimitiveWriter from './PrimitiveWriter.js';
import { BatchMeta, Timestamp } from '../common/messages.js';
export default class BatchWriter {
private nextIndex = 0
private beaconSize = 2 * 1e5 // Default 200kB
private writer = new PrimitiveWriter(this.beaconSize)
private isEmpty = true
private nextIndex = 0;
private beaconSize = 2 * 1e5; // Default 200kB
private writer = new PrimitiveWriter(this.beaconSize);
private isEmpty = true;
constructor(
private readonly pageNo: number,
private readonly pageNo: number,
private timestamp: number,
private onBatch: (batch: Uint8Array) => void
private readonly onBatch: (batch: Uint8Array) => void,
) {
this.prepare()
this.prepare();
}
private prepare(): void {
if (!this.writer.isEmpty()) {
return
return;
}
new BatchMeta(this.pageNo, this.nextIndex, this.timestamp).encode(this.writer)
new BatchMeta(this.pageNo, this.nextIndex, this.timestamp).encode(this.writer);
}
private write(message: Message): boolean {
const wasWritten = message.encode(this.writer)
const wasWritten = message.encode(this.writer);
if (wasWritten) {
this.isEmpty = false
this.writer.checkpoint()
this.nextIndex++
this.isEmpty = false;
this.writer.checkpoint();
this.nextIndex++;
}
return wasWritten
return wasWritten;
}
private beaconSizeLimit = 1e6
private beaconSizeLimit = 1e6;
setBeaconSizeLimit(limit: number) {
this.beaconSizeLimit = limit
this.beaconSizeLimit = limit;
}
writeMessage(message: Message) {
if (message instanceof Timestamp) {
this.timestamp = (<any>message).timestamp
this.timestamp = (<any>message).timestamp;
}
while (!this.write(message)) {
this.finaliseBatch()
this.finaliseBatch();
if (this.beaconSize === this.beaconSizeLimit) {
console.warn("OpenReplay: beacon size overflow. Skipping large message.");
this.writer.reset()
this.prepare()
this.isEmpty = true
return
console.warn('OpenReplay: beacon size overflow. Skipping large message.');
this.writer.reset();
this.prepare();
this.isEmpty = true;
return;
}
// MBTODO: tempWriter for one message?
this.beaconSize = Math.min(this.beaconSize*2, this.beaconSizeLimit)
this.writer = new PrimitiveWriter(this.beaconSize)
this.prepare()
this.isEmpty = true
this.beaconSize = Math.min(this.beaconSize * 2, this.beaconSizeLimit);
this.writer = new PrimitiveWriter(this.beaconSize);
this.prepare();
this.isEmpty = true;
}
}
finaliseBatch() {
if (this.isEmpty) { return }
this.onBatch(this.writer.flush())
this.prepare()
this.isEmpty = true
if (this.isEmpty) {
return;
}
this.onBatch(this.writer.flush());
this.prepare();
this.isEmpty = true;
}
clean() {
this.writer.reset()
this.writer.reset();
}
}

View file

@ -8,13 +8,13 @@ const textEncoder: { encode(str: string): Uint8Array } =
const Len = str.length,
resArr = new Uint8Array(Len * 3);
let resPos = -1;
for (var point = 0, nextcode = 0, i = 0; i !== Len; ) {
for (let point = 0, nextcode = 0, i = 0; i !== Len; ) {
(point = str.charCodeAt(i)), (i += 1);
if (point >= 0xd800 && point <= 0xdbff) {
if (i === Len) {
resArr[(resPos += 1)] = 0xef /*0b11101111*/;
resArr[(resPos += 1)] = 0xbf /*0b10111111*/;
resArr[(resPos += 1)] = 0xbd /*0b10111101*/;
resArr[(resPos += 1)] = 0xef; /*0b11101111*/
resArr[(resPos += 1)] = 0xbf; /*0b10111111*/
resArr[(resPos += 1)] = 0xbd; /*0b10111101*/
break;
}
// https://mathiasbynens.be/notes/javascript-encoding#surrogate-formulae
@ -23,21 +23,18 @@ const textEncoder: { encode(str: string): Uint8Array } =
point = (point - 0xd800) * 0x400 + nextcode - 0xdc00 + 0x10000;
i += 1;
if (point > 0xffff) {
resArr[(resPos += 1)] = (0x1e /*0b11110*/ << 3) | (point >>> 18);
resArr[(resPos += 1)] =
(0x1e /*0b11110*/ << 3) | (point >>> 18);
(0x2 /*0b10*/ << 6) | ((point >>> 12) & 0x3f); /*0b00111111*/
resArr[(resPos += 1)] =
(0x2 /*0b10*/ << 6) |
((point >>> 12) & 0x3f) /*0b00111111*/;
resArr[(resPos += 1)] =
(0x2 /*0b10*/ << 6) | ((point >>> 6) & 0x3f) /*0b00111111*/;
resArr[(resPos += 1)] =
(0x2 /*0b10*/ << 6) | (point & 0x3f) /*0b00111111*/;
(0x2 /*0b10*/ << 6) | ((point >>> 6) & 0x3f); /*0b00111111*/
resArr[(resPos += 1)] = (0x2 /*0b10*/ << 6) | (point & 0x3f); /*0b00111111*/
continue;
}
} else {
resArr[(resPos += 1)] = 0xef /*0b11101111*/;
resArr[(resPos += 1)] = 0xbf /*0b10111111*/;
resArr[(resPos += 1)] = 0xbd /*0b10111101*/;
resArr[(resPos += 1)] = 0xef; /*0b11101111*/
resArr[(resPos += 1)] = 0xbf; /*0b10111111*/
resArr[(resPos += 1)] = 0xbd; /*0b10111101*/
continue;
}
}
@ -45,14 +42,11 @@ const textEncoder: { encode(str: string): Uint8Array } =
resArr[(resPos += 1)] = (0x0 /*0b0*/ << 7) | point;
} else if (point <= 0x07ff) {
resArr[(resPos += 1)] = (0x6 /*0b110*/ << 5) | (point >>> 6);
resArr[(resPos += 1)] =
(0x2 /*0b10*/ << 6) | (point & 0x3f) /*0b00111111*/;
resArr[(resPos += 1)] = (0x2 /*0b10*/ << 6) | (point & 0x3f); /*0b00111111*/
} else {
resArr[(resPos += 1)] = (0xe /*0b1110*/ << 4) | (point >>> 12);
resArr[(resPos += 1)] =
(0x2 /*0b10*/ << 6) | ((point >>> 6) & 0x3f) /*0b00111111*/;
resArr[(resPos += 1)] =
(0x2 /*0b10*/ << 6) | (point & 0x3f) /*0b00111111*/;
resArr[(resPos += 1)] = (0x2 /*0b10*/ << 6) | ((point >>> 6) & 0x3f); /*0b00111111*/
resArr[(resPos += 1)] = (0x2 /*0b10*/ << 6) | (point & 0x3f); /*0b00111111*/
}
}
return resArr.subarray(0, resPos + 1);
@ -60,13 +54,13 @@ const textEncoder: { encode(str: string): Uint8Array } =
};
export default class PrimitiveWriter {
private offset: number = 0;
private checkpointOffset: number = 0;
private offset = 0;
private checkpointOffset = 0;
private readonly data: Uint8Array;
constructor(private readonly size: number) {
this.data = new Uint8Array(size);
}
checkpoint(): void {
checkpoint() {
this.checkpointOffset = this.offset;
}
isEmpty(): boolean {
@ -78,7 +72,7 @@ export default class PrimitiveWriter {
}
uint(value: number): boolean {
if (value < 0 || value > Number.MAX_SAFE_INTEGER) {
value = 0
value = 0;
}
while (value >= 0x80) {
this.data[this.offset++] = value % 0x100 | 0x80;
@ -95,7 +89,7 @@ export default class PrimitiveWriter {
const encoded = textEncoder.encode(value);
const length = encoded.byteLength;
if (!this.uint(length) || this.offset + length > this.size) {
return false
return false;
}
this.data.set(encoded, this.offset);
this.offset += length;

View file

@ -1,6 +1,6 @@
const INGEST_PATH = "/v1/web/i"
const INGEST_PATH = '/v1/web/i';
const KEEPALIVE_SIZE_LIMIT = 64 << 10 // 64 kB
const KEEPALIVE_SIZE_LIMIT = 64 << 10; // 64 kB
// function sendXHR(url: string, token: string, batch: Uint8Array): Promise<XMLHttpRequest> {
// const req = new XMLHttpRequest()
@ -20,88 +20,83 @@ const KEEPALIVE_SIZE_LIMIT = 64 << 10 // 64 kB
// })
// }
export default class QueueSender {
private attemptsCount = 0
private busy = false
private readonly queue: Array<Uint8Array> = []
private readonly ingestURL
private token: string | null = null
private attemptsCount = 0;
private busy = false;
private readonly queue: Array<Uint8Array> = [];
private readonly ingestURL;
private token: string | null = null;
constructor(
ingestBaseURL: string,
private readonly onUnauthorised: Function,
private readonly onFailure: Function,
ingestBaseURL: string,
private readonly onUnauthorised: () => any,
private readonly onFailure: () => any,
private readonly MAX_ATTEMPTS_COUNT = 10,
private readonly ATTEMPT_TIMEOUT = 1000,
) {
this.ingestURL = ingestBaseURL + INGEST_PATH
this.ingestURL = ingestBaseURL + INGEST_PATH;
}
authorise(token: string) {
this.token = token
authorise(token: string): void {
this.token = token;
}
push(batch: Uint8Array) {
push(batch: Uint8Array): void {
if (this.busy || !this.token) {
this.queue.push(batch)
this.queue.push(batch);
} else {
this.sendBatch(batch)
this.sendBatch(batch);
}
}
private retry(batch: Uint8Array) {
private retry(batch: Uint8Array): void {
if (this.attemptsCount >= this.MAX_ATTEMPTS_COUNT) {
this.onFailure()
return
this.onFailure();
return;
}
this.attemptsCount++
setTimeout(() => this.sendBatch(batch), this.ATTEMPT_TIMEOUT * this.attemptsCount)
this.attemptsCount++;
setTimeout(() => this.sendBatch(batch), this.ATTEMPT_TIMEOUT * this.attemptsCount);
}
// would be nice to use Beacon API, but it is not available in WebWorker
private sendBatch(batch: Uint8Array):void {
this.busy = true
private sendBatch(batch: Uint8Array): void {
this.busy = true;
fetch(this.ingestURL, {
body: batch,
method: 'POST',
headers: {
"Authorization": "Bearer " + this.token,
Authorization: 'Bearer ' + this.token,
//"Content-Type": "",
},
keepalive: batch.length < KEEPALIVE_SIZE_LIMIT,
})
.then(r => {
if (r.status === 401) { // TODO: continuous session ?
this.busy = false
this.onUnauthorised()
return
} else if (r.status >= 400) {
this.retry(batch)
return
}
// Success
this.attemptsCount = 0
const nextBatch = this.queue.shift()
if (nextBatch) {
this.sendBatch(nextBatch)
} else {
this.busy = false
}
})
.catch(e => {
console.warn("OpenReplay:", e)
this.retry(batch)
})
.then((r) => {
if (r.status === 401) {
// TODO: continuous session ?
this.busy = false;
this.onUnauthorised();
return;
} else if (r.status >= 400) {
this.retry(batch);
return;
}
// Success
this.attemptsCount = 0;
const nextBatch = this.queue.shift();
if (nextBatch) {
this.sendBatch(nextBatch);
} else {
this.busy = false;
}
})
.catch((e) => {
console.warn('OpenReplay:', e);
this.retry(batch);
});
}
clean() {
this.queue.length = 0
this.queue.length = 0;
}
}

View file

@ -1,126 +1,125 @@
import type Message from "../common/messages.js";
import { WorkerMessageData } from "../common/webworker.js";
import type Message from '../common/messages.js';
import { WorkerMessageData } from '../common/webworker.js';
import {
classes,
SetPageVisibility,
MouseMove,
} from "../common/messages.js";
import QueueSender from "./QueueSender.js";
import BatchWriter from "./BatchWriter.js";
import { classes, SetPageVisibility } from '../common/messages.js';
import QueueSender from './QueueSender.js';
import BatchWriter from './BatchWriter.js';
enum WorkerStatus {
NotActive,
Starting,
Stopping,
Active
Active,
}
const AUTO_SEND_INTERVAL = 10 * 1000
const AUTO_SEND_INTERVAL = 10 * 1000;
let sender: QueueSender | null = null
let writer: BatchWriter | null = null
let sender: QueueSender | null = null;
let writer: BatchWriter | null = null;
let workerStatus: WorkerStatus = WorkerStatus.NotActive;
function send(): void {
if (!writer) {
return
return;
}
writer.finaliseBatch()
writer.finaliseBatch();
}
function reset() {
workerStatus = WorkerStatus.Stopping
function reset(): void {
workerStatus = WorkerStatus.Stopping;
if (sendIntervalID !== null) {
clearInterval(sendIntervalID);
sendIntervalID = null;
}
if (writer) {
writer.clean()
writer = null
writer.clean();
writer = null;
}
workerStatus = WorkerStatus.NotActive
workerStatus = WorkerStatus.NotActive;
}
function resetCleanQueue() {
function resetCleanQueue(): void {
if (sender) {
sender.clean()
sender = null
}
reset()
sender.clean();
sender = null;
}
reset();
}
let sendIntervalID: ReturnType<typeof setInterval> | null = null
let restartTimeoutID: ReturnType<typeof setTimeout>
let sendIntervalID: ReturnType<typeof setInterval> | null = null;
let restartTimeoutID: ReturnType<typeof setTimeout>;
self.onmessage = ({ data }: MessageEvent<WorkerMessageData>) => {
self.onmessage = ({ data }: MessageEvent<WorkerMessageData>): any => {
if (data == null) {
send() // TODO: sendAll?
return
send(); // TODO: sendAll?
return;
}
if (data === "stop") {
send()
reset()
return
if (data === 'stop') {
send();
reset();
return;
}
if (Array.isArray(data)) {
if (!writer) {
throw new Error("WebWorker: writer not initialised. Service Should be Started.")
throw new Error('WebWorker: writer not initialised. Service Should be Started.');
}
const w = writer
const w = writer;
// Message[]
data.forEach((data) => {
const message: Message = new (<any>classes.get(data._id))();
Object.assign(message, data)
// @ts-ignore
const message: Message = new (classes.get(data._id))();
Object.assign(message, data);
if (message instanceof SetPageVisibility) {
if ( (<any>message).hidden) {
restartTimeoutID = setTimeout(() => self.postMessage("restart"), 30*60*1000)
// @ts-ignore
if ((<any>message).hidden) {
restartTimeoutID = setTimeout(() => self.postMessage('restart'), 30 * 60 * 1000);
} else {
clearTimeout(restartTimeoutID)
clearTimeout(restartTimeoutID);
}
}
w.writeMessage(message)
})
return
}
w.writeMessage(message);
});
return;
}
if (data.type === 'start') {
workerStatus = WorkerStatus.Starting
workerStatus = WorkerStatus.Starting;
sender = new QueueSender(
data.ingestPoint,
() => { // onUnauthorised
self.postMessage("restart")
() => {
// onUnauthorised
self.postMessage('restart');
},
() => { // onFailure
resetCleanQueue()
self.postMessage("failed")
() => {
// onFailure
resetCleanQueue();
self.postMessage('failed');
},
data.connAttemptCount,
data.connAttemptGap,
)
);
writer = new BatchWriter(
data.pageNo,
data.timestamp,
// onBatch
batch => sender && sender.push(batch)
)
(batch) => sender && sender.push(batch),
);
if (sendIntervalID === null) {
sendIntervalID = setInterval(send, AUTO_SEND_INTERVAL)
sendIntervalID = setInterval(send, AUTO_SEND_INTERVAL);
}
return workerStatus = WorkerStatus.Active
return (workerStatus = WorkerStatus.Active);
}
if (data.type === "auth") {
if (data.type === 'auth') {
if (!sender) {
throw new Error("WebWorker: sender not initialised. Received auth.")
throw new Error('WebWorker: sender not initialised. Received auth.');
}
if (!writer) {
throw new Error("WebWorker: writer not initialised. Received auth.")
throw new Error('WebWorker: writer not initialised. Received auth.');
}
sender.authorise(data.token)
data.beaconSizeLimit && writer.setBeaconSizeLimit(data.beaconSizeLimit)
return
sender.authorise(data.token);
data.beaconSizeLimit && writer.setBeaconSizeLimit(data.beaconSizeLimit);
return;
}
};

View file

@ -3,7 +3,5 @@
"compilerOptions": {
"lib": ["es6", "webworker"]
},
"references": [
{ "path": "../common" }
]
"references": [{ "path": "../common" }]
}