style(tracker): who needs semicolons?!

This commit is contained in:
Alex Kaminskii 2022-08-03 12:55:25 +02:00
parent 9292ba494f
commit 251182b5b3
33 changed files with 1544 additions and 1546 deletions

View file

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

View file

@ -1,22 +1,22 @@
import Message from './messages.gen.js';
import Message from './messages.gen.js'
export interface Options {
connAttemptCount?: number;
connAttemptGap?: number;
connAttemptCount?: number
connAttemptGap?: number
}
type Start = {
type: 'start';
ingestPoint: string;
pageNo: number;
timestamp: number;
url: string;
} & Options;
type: 'start'
ingestPoint: string
pageNo: number
timestamp: number
url: string
} & Options
type Auth = {
type: 'auth';
token: string;
beaconSizeLimit?: number;
};
type: 'auth'
token: string
beaconSizeLimit?: number
}
export type WorkerMessageData = null | 'stop' | Start | Auth | Array<Message>;
export type WorkerMessageData = null | 'stop' | Start | Auth | Array<Message>

View file

@ -1,34 +1,34 @@
export function isSVGElement(node: Element): node is SVGElement {
return node.namespaceURI === 'http://www.w3.org/2000/svg';
return node.namespaceURI === 'http://www.w3.org/2000/svg'
}
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;
};
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;
return el.nodeName === tagName
}

View file

@ -1,45 +1,45 @@
import type Message from './messages.gen.js';
import { Timestamp, Metadata, UserID } from './messages.gen.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 './messages.gen.js'
import { Timestamp, Metadata, UserID } from './messages.gen.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/interaction.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/interaction.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,
@ -47,51 +47,51 @@ enum ActivityState {
}
type AppOptions = {
revID: string;
node_id: string;
session_token_key: string;
session_pageno_key: string;
session_reset_key: string;
local_uuid_key: string;
ingestPoint: string;
resourceBaseHref: string | null; // resourceHref?
revID: string
node_id: string
session_token_key: string
session_pageno_key: string
session_reset_key: string
local_uuid_key: string
ingestPoint: string
resourceBaseHref: string | null // resourceHref?
//resourceURLRewriter: (url: string) => string | boolean,
verbose: boolean;
__is_snippet: boolean;
__debug_report_edp: string | null;
__debug__?: LoggerOptions;
localStorage: Storage;
sessionStorage: Storage;
verbose: boolean
__is_snippet: boolean
__debug_report_edp: string | null
__debug__?: LoggerOptions
localStorage: Storage
sessionStorage: Storage
// @deprecated
onStart?: StartCallback;
} & WebworkerOptions;
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';
export const DEFAULT_INGEST_POINT = 'https://api.openreplay.com/ingest'
export default class App {
readonly nodes: Nodes;
readonly ticker: Ticker;
readonly projectKey: string;
readonly sanitizer: Sanitizer;
readonly debug: Logger;
readonly notify: Logger;
readonly session: Session;
readonly localStorage: Storage;
readonly sessionStorage: Storage;
private readonly messages: Array<Message> = [];
private readonly observer: Observer;
private readonly startCallbacks: Array<StartCallback> = [];
private readonly stopCallbacks: Array<() => any> = [];
private readonly commitCallbacks: Array<CommitCallback> = [];
private readonly options: AppOptions;
private readonly revID: string;
private activityState: ActivityState = ActivityState.NotActive;
private readonly version = 'TRACKER_VERSION'; // TODO: version compatability check inside each plugin.
private readonly worker?: Worker;
readonly nodes: Nodes
readonly ticker: Ticker
readonly projectKey: string
readonly sanitizer: Sanitizer
readonly debug: Logger
readonly notify: Logger
readonly session: Session
readonly localStorage: Storage
readonly sessionStorage: Storage
private readonly messages: Array<Message> = []
private readonly observer: Observer
private readonly startCallbacks: Array<StartCallback> = []
private readonly stopCallbacks: Array<() => any> = []
private readonly commitCallbacks: Array<CommitCallback> = []
private readonly options: AppOptions
private readonly revID: string
private activityState: ActivityState = ActivityState.NotActive
private readonly version = 'TRACKER_VERSION' // TODO: version compatability check inside each plugin.
private readonly worker?: Worker
constructor(
projectKey: string,
sessionToken: string | null | undefined,
@ -101,7 +101,7 @@ export default class App {
// deprecationWarn("'onStart' option", "tracker.start().then(/* handle session info */)")
// } ?? maybe onStart is good
this.projectKey = projectKey;
this.projectKey = projectKey
this.options = Object.assign(
{
revID: '',
@ -119,61 +119,61 @@ export default class App {
sessionStorage: window.sessionStorage,
},
options,
);
)
this.revID = this.options.revID;
this.sanitizer = new Sanitizer(this, options);
this.nodes = new Nodes(this.options.node_id);
this.observer = new Observer(this, options);
this.ticker = new Ticker(this);
this.ticker.attach(() => this.commit());
this.debug = new Logger(this.options.__debug__);
this.notify = new Logger(this.options.verbose ? LogLevel.Warnings : LogLevel.Silent);
this.session = new Session();
this.revID = this.options.revID
this.sanitizer = new Sanitizer(this, options)
this.nodes = new Nodes(this.options.node_id)
this.observer = new Observer(this, options)
this.ticker = new Ticker(this)
this.ticker.attach(() => this.commit())
this.debug = new Logger(this.options.__debug__)
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(UserID(userID));
this.send(UserID(userID))
}
if (metadata != null) {
Object.entries(metadata).forEach(([key, value]) => this.send(Metadata(key, value)));
Object.entries(metadata).forEach(([key, value]) => this.send(Metadata(key, value)))
}
});
this.localStorage = this.options.localStorage;
this.sessionStorage = this.options.sessionStorage;
})
this.localStorage = this.options.localStorage
this.sessionStorage = this.options.sessionStorage
if (sessionToken != null) {
this.sessionStorage.setItem(this.options.session_token_key, sessionToken);
this.sessionStorage.setItem(this.options.session_token_key, sessionToken)
}
try {
this.worker = new Worker(
URL.createObjectURL(new Blob(['WEBWORKER_BODY'], { type: 'text/javascript' })),
);
)
this.worker.onerror = (e) => {
this._debug('webworker_error', e);
};
this._debug('webworker_error', e)
}
this.worker.onmessage = ({ data }: MessageEvent) => {
if (data === 'failed') {
this.stop();
this._debug('worker_failed', {}); // add context (from worker)
this.stop()
this._debug('worker_failed', {}) // add context (from worker)
} else if (data === 'restart') {
this.stop();
this.start({ forceNew: true });
this.stop()
this.start({ forceNew: true })
}
};
}
const alertWorker = () => {
if (this.worker) {
this.worker.postMessage(null);
this.worker.postMessage(null)
}
};
}
// keep better tactics, discard others?
this.attachEventListener(window, 'beforeunload', alertWorker, false);
this.attachEventListener(document.body, 'mouseleave', alertWorker, false, false);
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);
this.attachEventListener(document, 'visibilitychange', alertWorker, false)
} catch (e) {
this._debug('worker_start', e);
this._debug('worker_start', e)
}
}
@ -186,56 +186,56 @@ export default class App {
context,
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;
return
}
this.messages.push(message);
this.messages.push(message)
// 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
// (like Fetch before start; maybe add an option "preCapture: boolean" or sth alike)
if (this.activityState === ActivityState.Active && urgent) {
this.commit();
this.commit()
}
}
private commit(): void {
if (this.worker && this.messages.length) {
this.messages.unshift(Timestamp(timestamp()));
this.worker.postMessage(this.messages);
this.commitCallbacks.forEach((cb) => cb(this.messages));
this.messages.length = 0;
this.messages.unshift(Timestamp(timestamp()))
this.worker.postMessage(this.messages)
this.commitCallbacks.forEach((cb) => cb(this.messages))
this.messages.length = 0
}
}
safe<T extends (...args: any[]) => void>(fn: T): T {
const app = this;
const app = this
return function (this: any, ...args: any) {
try {
fn.apply(this, args);
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);
this.startCallbacks.push(cb)
}
attachStopCallback(cb: () => any): void {
this.stopCallbacks.push(cb);
this.stopCallbacks.push(cb)
}
attachEventListener(
target: EventTarget,
@ -245,22 +245,22 @@ export default class App {
useCapture = true,
): void {
if (useSafe) {
listener = this.safe(listener);
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() {
@ -271,88 +271,88 @@ 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(),
};
}
}
getSessionToken(): string | undefined {
const token = this.sessionStorage.getItem(this.options.session_token_key);
const token = this.sessionStorage.getItem(this.options.session_token_key)
if (token !== null) {
return token;
return token
}
}
getSessionID(): string | undefined {
return this.session.getInfo().sessionID || undefined;
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
);
)
}
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) {
if (flag) {
this.sessionStorage.setItem(this.options.session_reset_key, 't');
this.sessionStorage.setItem(this.options.session_reset_key, 't')
} else {
this.sessionStorage.removeItem(this.options.session_reset_key);
this.sessionStorage.removeItem(this.options.session_reset_key)
}
}
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.',
),
);
)
}
this.activityState = ActivityState.Starting;
this.activityState = ActivityState.Starting
let pageNo = 0;
const pageNoStr = this.sessionStorage.getItem(this.options.session_pageno_key);
let pageNo = 0
const pageNoStr = this.sessionStorage.getItem(this.options.session_pageno_key)
if (pageNoStr != null) {
pageNo = parseInt(pageNoStr);
pageNo++;
pageNo = parseInt(pageNoStr)
pageNo++
}
this.sessionStorage.setItem(this.options.session_pageno_key, pageNo.toString());
this.sessionStorage.setItem(this.options.session_pageno_key, pageNo.toString())
const startInfo = this.getStartInfo();
const startInfo = this.getStartInfo()
const startWorkerMsg: WorkerMessageData = {
type: 'start',
pageNo,
@ -361,8 +361,8 @@ export default class App {
url: document.URL,
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.
@ -370,10 +370,10 @@ export default class App {
// (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);
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', {
@ -392,7 +392,7 @@ export default class App {
})
.then((r) => {
if (r.status === 200) {
return r.json();
return r.json()
} else {
return r
.text()
@ -400,96 +400,96 @@ export default class App {
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)');
return Promise.reject('no worker found after start request (this might not happen)')
}
const { token, userUUID, sessionID, beaconSizeLimit } = r;
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)}`);
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'
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.worker.postMessage(startWorkerMsg)
this.activityState = ActivityState.Active;
this.activityState = ActivityState.Active
const onStartInfo = { sessionToken: token, userUUID, sessionID };
const onStartInfo = { sessionToken: token, userUUID, sessionID }
this.startCallbacks.forEach((cb) => cb(onStartInfo)); // TODO: start as early as possible (before receiving the token)
this.observer.observe();
this.ticker.start();
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 tracking started.');
this.notify.log('OpenReplay tracking started.')
// get rid of onStart ?
if (typeof this.options.onStart === 'function') {
this.options.onStart(onStartInfo);
this.options.onStart(onStartInfo)
}
return SuccessfulStart(onStartInfo);
return SuccessfulStart(onStartInfo)
})
.catch((reason) => {
this.sessionStorage.removeItem(this.options.session_token_key);
this.stop();
this.sessionStorage.removeItem(this.options.session_token_key)
this.stop()
if (reason === CANCELED) {
return UnsuccessfulStart(CANCELED);
return UnsuccessfulStart(CANCELED)
}
this.notify.log('OpenReplay was unable to start. ', reason);
this._debug('session_start', reason);
return UnsuccessfulStart(START_ERROR);
});
this.notify.log('OpenReplay was unable to start. ', reason)
this._debug('session_start', reason)
return UnsuccessfulStart(START_ERROR)
})
}
start(options: StartOptions = {}): Promise<StartPromiseReturn> {
if (!document.hidden) {
return this._start(options);
return this._start(options)
} else {
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, restarting = 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 && !restarting) {
this.worker.postMessage('stop');
this.worker.postMessage('stop')
}
} finally {
this.activityState = ActivityState.NotActive;
this.activityState = ActivityState.NotActive
}
}
}
restart() {
this.stop(false, true);
this.start({ forceNew: false });
this.stop(false, true)
this.start({ forceNew: false })
}
}

View file

@ -4,35 +4,35 @@ export const LogLevel = {
Warnings: 3,
Errors: 2,
Silent: 0,
} as const;
type LogLevel = typeof LogLevel[keyof typeof LogLevel];
} as const
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;
private readonly options: _Options
constructor(options: Options = LogLevel.Silent) {
this.options =
options === true
? { level: LogLevel.Verbose }
: typeof options === 'number'
? { level: options }
: options;
: options
}
log(...args: any) {
if (
@ -40,7 +40,7 @@ export default class Logger {
? this.options.level.log
: this.options.level >= LogLevel.Log
) {
console.log(...args);
console.log(...args)
}
}
warn(...args: any) {
@ -49,7 +49,7 @@ export default class Logger {
? this.options.level.warn
: this.options.level >= LogLevel.Warnings
) {
console.warn(...args);
console.warn(...args)
}
}
error(...args: any) {
@ -58,7 +58,7 @@ export default class Logger {
? this.options.level.error
: this.options.level >= LogLevel.Errors
) {
console.error(...args);
console.error(...args)
}
}
}

View file

@ -1,7 +1,7 @@
// Auto-generated, do not edit
import * as Messages from '../../common/messages.gen.js';
export { default } from '../../common/messages.gen.js';
import * as Messages from '../../common/messages.gen.js'
export { default } from '../../common/messages.gen.js'
export function BatchMetadata(
version: number,
@ -10,15 +10,15 @@ export function BatchMetadata(
timestamp: number,
location: string,
): Messages.BatchMetadata {
return [Messages.Type.BatchMetadata, version, pageNo, firstIndex, timestamp, location];
return [Messages.Type.BatchMetadata, version, pageNo, firstIndex, timestamp, location]
}
export function PartitionedMessage(partNo: number, partTotal: number): Messages.PartitionedMessage {
return [Messages.Type.PartitionedMessage, partNo, partTotal];
return [Messages.Type.PartitionedMessage, partNo, partTotal]
}
export function Timestamp(timestamp: number): Messages.Timestamp {
return [Messages.Type.Timestamp, timestamp];
return [Messages.Type.Timestamp, timestamp]
}
export function SetPageLocation(
@ -26,19 +26,19 @@ export function SetPageLocation(
referrer: string,
navigationStart: number,
): Messages.SetPageLocation {
return [Messages.Type.SetPageLocation, url, referrer, navigationStart];
return [Messages.Type.SetPageLocation, url, referrer, navigationStart]
}
export function SetViewportSize(width: number, height: number): Messages.SetViewportSize {
return [Messages.Type.SetViewportSize, width, height];
return [Messages.Type.SetViewportSize, width, height]
}
export function SetViewportScroll(x: number, y: number): Messages.SetViewportScroll {
return [Messages.Type.SetViewportScroll, x, y];
return [Messages.Type.SetViewportScroll, x, y]
}
export function CreateDocument(): Messages.CreateDocument {
return [Messages.Type.CreateDocument];
return [Messages.Type.CreateDocument]
}
export function CreateElementNode(
@ -48,7 +48,7 @@ export function CreateElementNode(
tag: string,
svg: boolean,
): Messages.CreateElementNode {
return [Messages.Type.CreateElementNode, id, parentID, index, tag, svg];
return [Messages.Type.CreateElementNode, id, parentID, index, tag, svg]
}
export function CreateTextNode(
@ -56,15 +56,15 @@ export function CreateTextNode(
parentID: number,
index: number,
): Messages.CreateTextNode {
return [Messages.Type.CreateTextNode, id, parentID, index];
return [Messages.Type.CreateTextNode, id, parentID, index]
}
export function MoveNode(id: number, parentID: number, index: number): Messages.MoveNode {
return [Messages.Type.MoveNode, id, parentID, index];
return [Messages.Type.MoveNode, id, parentID, index]
}
export function RemoveNode(id: number): Messages.RemoveNode {
return [Messages.Type.RemoveNode, id];
return [Messages.Type.RemoveNode, id]
}
export function SetNodeAttribute(
@ -72,39 +72,39 @@ export function SetNodeAttribute(
name: string,
value: string,
): Messages.SetNodeAttribute {
return [Messages.Type.SetNodeAttribute, id, name, value];
return [Messages.Type.SetNodeAttribute, id, name, value]
}
export function RemoveNodeAttribute(id: number, name: string): Messages.RemoveNodeAttribute {
return [Messages.Type.RemoveNodeAttribute, id, name];
return [Messages.Type.RemoveNodeAttribute, id, name]
}
export function SetNodeData(id: number, data: string): Messages.SetNodeData {
return [Messages.Type.SetNodeData, id, data];
return [Messages.Type.SetNodeData, id, data]
}
export function SetNodeScroll(id: number, x: number, y: number): Messages.SetNodeScroll {
return [Messages.Type.SetNodeScroll, id, x, y];
return [Messages.Type.SetNodeScroll, id, x, y]
}
export function SetInputTarget(id: number, label: string): Messages.SetInputTarget {
return [Messages.Type.SetInputTarget, id, label];
return [Messages.Type.SetInputTarget, id, label]
}
export function SetInputValue(id: number, value: string, mask: number): Messages.SetInputValue {
return [Messages.Type.SetInputValue, id, value, mask];
return [Messages.Type.SetInputValue, id, value, mask]
}
export function SetInputChecked(id: number, checked: boolean): Messages.SetInputChecked {
return [Messages.Type.SetInputChecked, id, checked];
return [Messages.Type.SetInputChecked, id, checked]
}
export function MouseMove(x: number, y: number): Messages.MouseMove {
return [Messages.Type.MouseMove, x, y];
return [Messages.Type.MouseMove, x, y]
}
export function ConsoleLog(level: string, value: string): Messages.ConsoleLog {
return [Messages.Type.ConsoleLog, level, value];
return [Messages.Type.ConsoleLog, level, value]
}
export function PageLoadTiming(
@ -129,7 +129,7 @@ export function PageLoadTiming(
loadEventEnd,
firstPaint,
firstContentfulPaint,
];
]
}
export function PageRenderTiming(
@ -137,35 +137,35 @@ export function PageRenderTiming(
visuallyComplete: number,
timeToInteractive: number,
): Messages.PageRenderTiming {
return [Messages.Type.PageRenderTiming, speedIndex, visuallyComplete, timeToInteractive];
return [Messages.Type.PageRenderTiming, speedIndex, visuallyComplete, timeToInteractive]
}
export function JSException(name: string, message: string, payload: string): Messages.JSException {
return [Messages.Type.JSException, name, message, payload];
return [Messages.Type.JSException, name, message, payload]
}
export function RawCustomEvent(name: string, payload: string): Messages.RawCustomEvent {
return [Messages.Type.RawCustomEvent, name, payload];
return [Messages.Type.RawCustomEvent, name, payload]
}
export function UserID(id: string): Messages.UserID {
return [Messages.Type.UserID, id];
return [Messages.Type.UserID, id]
}
export function UserAnonymousID(id: string): Messages.UserAnonymousID {
return [Messages.Type.UserAnonymousID, id];
return [Messages.Type.UserAnonymousID, id]
}
export function Metadata(key: string, value: string): Messages.Metadata {
return [Messages.Type.Metadata, key, value];
return [Messages.Type.Metadata, key, value]
}
export function CSSInsertRule(id: number, rule: string, index: number): Messages.CSSInsertRule {
return [Messages.Type.CSSInsertRule, id, rule, index];
return [Messages.Type.CSSInsertRule, id, rule, index]
}
export function CSSDeleteRule(id: number, index: number): Messages.CSSDeleteRule {
return [Messages.Type.CSSDeleteRule, id, index];
return [Messages.Type.CSSDeleteRule, id, index]
}
export function Fetch(
@ -177,7 +177,7 @@ export function Fetch(
timestamp: number,
duration: number,
): Messages.Fetch {
return [Messages.Type.Fetch, method, url, request, response, status, timestamp, duration];
return [Messages.Type.Fetch, method, url, request, response, status, timestamp, duration]
}
export function Profiler(
@ -186,31 +186,31 @@ export function Profiler(
args: string,
result: string,
): Messages.Profiler {
return [Messages.Type.Profiler, name, duration, args, result];
return [Messages.Type.Profiler, name, duration, args, result]
}
export function OTable(key: string, value: string): Messages.OTable {
return [Messages.Type.OTable, key, value];
return [Messages.Type.OTable, key, value]
}
export function StateAction(type: string): Messages.StateAction {
return [Messages.Type.StateAction, type];
return [Messages.Type.StateAction, type]
}
export function Redux(action: string, state: string, duration: number): Messages.Redux {
return [Messages.Type.Redux, action, state, duration];
return [Messages.Type.Redux, action, state, duration]
}
export function Vuex(mutation: string, state: string): Messages.Vuex {
return [Messages.Type.Vuex, mutation, state];
return [Messages.Type.Vuex, mutation, state]
}
export function MobX(type: string, payload: string): Messages.MobX {
return [Messages.Type.MobX, type, payload];
return [Messages.Type.MobX, type, payload]
}
export function NgRx(action: string, state: string, duration: number): Messages.NgRx {
return [Messages.Type.NgRx, action, state, duration];
return [Messages.Type.NgRx, action, state, duration]
}
export function GraphQL(
@ -219,7 +219,7 @@ export function GraphQL(
variables: string,
response: string,
): Messages.GraphQL {
return [Messages.Type.GraphQL, operationKind, operationName, variables, response];
return [Messages.Type.GraphQL, operationKind, operationName, variables, response]
}
export function PerformanceTrack(
@ -228,7 +228,7 @@ export function PerformanceTrack(
totalJSHeapSize: number,
usedJSHeapSize: number,
): Messages.PerformanceTrack {
return [Messages.Type.PerformanceTrack, frames, ticks, totalJSHeapSize, usedJSHeapSize];
return [Messages.Type.PerformanceTrack, frames, ticks, totalJSHeapSize, usedJSHeapSize]
}
export function ResourceTiming(
@ -251,18 +251,18 @@ export function ResourceTiming(
decodedBodySize,
url,
initiator,
];
]
}
export function ConnectionInformation(
downlink: number,
type: string,
): Messages.ConnectionInformation {
return [Messages.Type.ConnectionInformation, downlink, type];
return [Messages.Type.ConnectionInformation, downlink, type]
}
export function SetPageVisibility(hidden: boolean): Messages.SetPageVisibility {
return [Messages.Type.SetPageVisibility, hidden];
return [Messages.Type.SetPageVisibility, hidden]
}
export function LongTask(
@ -283,7 +283,7 @@ export function LongTask(
containerSrc,
containerId,
containerName,
];
]
}
export function SetNodeAttributeURLBased(
@ -292,7 +292,7 @@ export function SetNodeAttributeURLBased(
value: string,
baseURL: string,
): Messages.SetNodeAttributeURLBased {
return [Messages.Type.SetNodeAttributeURLBased, id, name, value, baseURL];
return [Messages.Type.SetNodeAttributeURLBased, id, name, value, baseURL]
}
export function SetCSSDataURLBased(
@ -300,15 +300,15 @@ export function SetCSSDataURLBased(
data: string,
baseURL: string,
): Messages.SetCSSDataURLBased {
return [Messages.Type.SetCSSDataURLBased, id, data, baseURL];
return [Messages.Type.SetCSSDataURLBased, id, data, baseURL]
}
export function TechnicalInfo(type: string, value: string): Messages.TechnicalInfo {
return [Messages.Type.TechnicalInfo, type, value];
return [Messages.Type.TechnicalInfo, type, value]
}
export function CustomIssue(name: string, payload: string): Messages.CustomIssue {
return [Messages.Type.CustomIssue, name, payload];
return [Messages.Type.CustomIssue, name, payload]
}
export function CSSInsertRuleURLBased(
@ -317,7 +317,7 @@ export function CSSInsertRuleURLBased(
index: number,
baseURL: string,
): Messages.CSSInsertRuleURLBased {
return [Messages.Type.CSSInsertRuleURLBased, id, rule, index, baseURL];
return [Messages.Type.CSSInsertRuleURLBased, id, rule, index, baseURL]
}
export function MouseClick(
@ -326,9 +326,9 @@ export function MouseClick(
label: string,
selector: string,
): Messages.MouseClick {
return [Messages.Type.MouseClick, id, hesitationTime, label, selector];
return [Messages.Type.MouseClick, id, hesitationTime, label, selector]
}
export function CreateIFrameDocument(frameID: number, id: number): Messages.CreateIFrameDocument {
return [Messages.Type.CreateIFrameDocument, frameID, id];
return [Messages.Type.CreateIFrameDocument, frameID, id]
}

View file

@ -1,53 +1,53 @@
type NodeCallback = (node: Node, isStart: boolean) => void;
type ElementListener = [string, EventListener];
type NodeCallback = (node: Node, isStart: boolean) => void
type ElementListener = [string, EventListener]
export default class Nodes {
private nodes: Array<Node | void> = [];
private readonly nodeCallbacks: Array<NodeCallback> = [];
private readonly elementListeners: Map<number, Array<ElementListener>> = new Map();
private nodes: Array<Node | void> = []
private readonly nodeCallbacks: Array<NodeCallback> = []
private readonly elementListeners: Map<number, Array<ElementListener>> = new Map()
constructor(private readonly node_id: string) {}
attachNodeCallback(nodeCallback: NodeCallback): void {
this.nodeCallbacks.push(nodeCallback);
this.nodeCallbacks.push(nodeCallback)
}
attachElementListener(type: string, node: Element, elementListener: EventListener): void {
const id = this.getID(node);
const id = this.getID(node)
if (id === undefined) {
return;
return
}
node.addEventListener(type, elementListener);
let listeners = this.elementListeners.get(id);
node.addEventListener(type, elementListener)
let listeners = this.elementListeners.get(id)
if (listeners === undefined) {
listeners = [];
this.elementListeners.set(id, listeners);
return;
listeners = []
this.elementListeners.set(id, listeners)
return
}
listeners.push([type, elementListener]);
listeners.push([type, elementListener])
}
registerNode(node: Node): [id: number, isNew: boolean] {
let id: number = (node as any)[this.node_id];
const isNew = id === undefined;
let id: number = (node as any)[this.node_id]
const isNew = id === undefined
if (isNew) {
id = this.nodes.length;
this.nodes[id] = node;
(node as any)[this.node_id] = id;
id = this.nodes.length
this.nodes[id] = node
;(node as any)[this.node_id] = id
}
return [id, isNew];
return [id, isNew]
}
unregisterNode(node: Node): number | undefined {
const id = (node as any)[this.node_id];
const id = (node as any)[this.node_id]
if (id !== undefined) {
delete (node as any)[this.node_id];
delete this.nodes[id];
const listeners = this.elementListeners.get(id);
delete (node as any)[this.node_id]
delete this.nodes[id]
const listeners = this.elementListeners.get(id)
if (listeners !== undefined) {
this.elementListeners.delete(id);
listeners.forEach((listener) => node.removeEventListener(listener[0], listener[1]));
this.elementListeners.delete(id)
listeners.forEach((listener) => node.removeEventListener(listener[0], listener[1]))
}
}
return id;
return id
}
cleanTree() {
// sadly we keep empty items in array here resulting in some memory still being used
@ -55,30 +55,30 @@ export default class Nodes {
// plus we keep our index positions for new/alive nodes
// performance test: 3ms for 30k nodes with 17k dead ones
for (let i = 0; i < this.nodes.length; i++) {
const node = this.nodes[i];
const node = this.nodes[i]
if (node && !document.contains(node)) {
this.unregisterNode(node);
this.unregisterNode(node)
}
}
}
callNodeCallbacks(node: Node, isStart: boolean): void {
this.nodeCallbacks.forEach((cb) => cb(node, isStart));
this.nodeCallbacks.forEach((cb) => cb(node, isStart))
}
getID(node: Node): number | undefined {
return (node as any)[this.node_id];
return (node as any)[this.node_id]
}
getNode(id: number) {
return this.nodes[id];
return this.nodes[id]
}
clear(): void {
for (let id = 0; id < this.nodes.length; id++) {
const node = this.nodes[id];
const node = this.nodes[id]
if (node === undefined) {
continue;
continue
}
this.unregisterNode(node);
this.unregisterNode(node)
}
this.nodes.length = 0;
this.nodes.length = 0
}
}

View file

@ -1,20 +1,20 @@
import Observer from './observer.js';
import { CreateIFrameDocument } from '../messages.gen.js';
import Observer from './observer.js'
import { CreateIFrameDocument } from '../messages.gen.js'
export default class IFrameObserver extends Observer {
observe(iframe: HTMLIFrameElement) {
const doc = iframe.contentDocument;
const hostID = this.app.nodes.getID(iframe);
const doc = iframe.contentDocument
const hostID = this.app.nodes.getID(iframe)
if (!doc || hostID === undefined) {
return;
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');
return;
console.log('OpenReplay: Iframe document not bound')
return
}
this.app.send(CreateIFrameDocument(hostID, docID));
});
this.app.send(CreateIFrameDocument(hostID, docID))
})
}
}

View file

@ -8,33 +8,33 @@ import {
CreateElementNode,
MoveNode,
RemoveNode,
} from '../messages.gen.js';
import App from '../index.js';
import { isRootNode, isTextNode, isElementNode, isSVGElement, hasTag } from '../guards.js';
} from '../messages.gen.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;
return false
}
if (!isElementNode(node)) {
return true;
return true
}
const tag = node.tagName.toUpperCase();
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');
const rel = node.getAttribute('rel')
const as = node.getAttribute('as')
return !(rel?.includes('stylesheet') || as === 'style' || as === 'font')
}
return (
tag === 'SCRIPT' || tag === 'NOSCRIPT' || tag === 'META' || tag === 'TITLE' || tag === 'BASE'
);
)
}
function isObservable(node: Node): boolean {
if (isRootNode(node)) {
return true;
return true
}
return !isIgnored(node);
return !isIgnored(node)
}
/*
@ -57,84 +57,84 @@ 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 indexes: Array<number> = [];
private readonly attributesMap: Map<number, Set<string>> = new Map();
private readonly textSet: Set<number> = new Set();
private readonly observer: MutationObserver
private readonly commited: Array<boolean | undefined> = []
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
const target = mutation.target;
const type = mutation.type;
const target = mutation.target
const type = mutation.type
if (!isObservable(target)) {
continue;
continue
}
if (type === 'childList') {
for (let i = 0; i < mutation.removedNodes.length; i++) {
this.bindTree(mutation.removedNodes[i], true);
this.bindTree(mutation.removedNodes[i], true)
}
for (let i = 0; i < mutation.addedNodes.length; i++) {
this.bindTree(mutation.addedNodes[i]);
this.bindTree(mutation.addedNodes[i])
}
continue;
continue
}
const id = this.app.nodes.getID(target);
const id = this.app.nodes.getID(target)
if (id === undefined) {
continue;
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;
const name = mutation.attributeName
if (name === null) {
continue;
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;
attr.add(name)
continue
}
if (type === 'characterData') {
this.textSet.add(id);
continue;
this.textSet.add(id)
continue
}
}
this.commitNodes();
this.commitNodes()
}),
);
)
}
private clear(): void {
this.commited.length = 0;
this.recents.clear();
this.indexes.length = 1;
this.attributesMap.clear();
this.textSet.clear();
this.commited.length = 0
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 {
if (isSVGElement(node)) {
if (name.substr(0, 6) === 'xlink:') {
name = name.substr(6);
name = name.substr(6)
}
if (value === null) {
this.app.send(RemoveNodeAttribute(id, name));
this.app.send(RemoveNodeAttribute(id, name))
} else if (name === 'href') {
if (value.length > 1e5) {
value = '';
value = ''
}
this.app.send(SetNodeAttributeURLBased(id, name, value, this.app.getBaseHref()));
this.app.send(SetNodeAttributeURLBased(id, name, value, this.app.getBaseHref()))
} else {
this.app.send(SetNodeAttribute(id, name, value));
this.app.send(SetNodeAttribute(id, name, value))
}
return;
return
}
if (
name === 'src' ||
@ -144,7 +144,7 @@ export default abstract class Observer {
name === 'autocomplete' ||
name.substr(0, 2) === 'on'
) {
return;
return
}
if (
name === 'value' &&
@ -153,50 +153,50 @@ export default abstract class Observer {
node.type !== 'reset' &&
node.type !== 'submit'
) {
return;
return
}
if (value === null) {
this.app.send(RemoveNodeAttribute(id, name));
return;
this.app.send(RemoveNodeAttribute(id, name))
return
}
if (name === 'style' || (name === 'href' && hasTag(node, 'LINK'))) {
this.app.send(SetNodeAttributeURLBased(id, name, value, this.app.getBaseHref()));
return;
this.app.send(SetNodeAttributeURLBased(id, name, value, this.app.getBaseHref()))
return
}
if (name === 'href' || value.length > 1e5) {
value = '';
value = ''
}
this.app.send(SetNodeAttribute(id, name, value));
this.app.send(SetNodeAttribute(id, name, value))
}
private sendNodeData(id: number, parentElement: Element, data: string): void {
if (hasTag(parentElement, 'STYLE') || hasTag(parentElement, 'style')) {
this.app.send(SetCSSDataURLBased(id, data, this.app.getBaseHref()));
return;
this.app.send(SetCSSDataURLBased(id, data, this.app.getBaseHref()))
return
}
data = this.app.sanitizer.sanitize(id, data);
this.app.send(SetNodeData(id, data));
data = this.app.sanitizer.sanitize(id, data)
this.app.send(SetNodeData(id, data))
}
private bindNode(node: Node): void {
const [id, isNew] = this.app.nodes.registerNode(node);
const [id, isNew] = this.app.nodes.registerNode(node)
if (isNew) {
this.recents.set(id, RecentsType.New);
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);
this.recents.set(id, RecentsType.Removed)
}
}
private unbindChildNode(node: Node): void {
const [id] = this.app.nodes.registerNode(node);
this.recents.set(id, RecentsType.RemovedChild);
const [id] = this.app.nodes.registerNode(node)
this.recents.set(id, RecentsType.RemovedChild)
}
private bindTree(node: Node, isChildUnbinding = false): void {
if (!isObservable(node)) {
return;
return
}
this.bindNode(node);
this.bindNode(node)
const walker = document.createTreeWalker(
node,
NodeFilter.SHOW_ELEMENT + NodeFilter.SHOW_TEXT,
@ -208,30 +208,30 @@ export default abstract class Observer {
},
// @ts-ignore
false,
);
)
while (walker.nextNode()) {
if (isChildUnbinding) {
this.unbindChildNode(walker.currentNode);
this.unbindChildNode(walker.currentNode)
} else {
this.bindNode(walker.currentNode);
this.bindNode(walker.currentNode)
}
}
}
private unbindNode(node: Node) {
const id = this.app.nodes.unregisterNode(node);
const id = this.app.nodes.unregisterNode(node)
if (id !== undefined && this.recents.get(id) === RecentsType.Removed) {
this.app.send(RemoveNode(id));
this.app.send(RemoveNode(id))
}
}
// A top-consumption function on the infinite lists test. (~1% of performance resources)
private _commitNode(id: number, node: Node): boolean {
if (isRootNode(node)) {
return true;
return true
}
const parent = node.parentNode;
let parentID: number | undefined;
const parent = node.parentNode
let parentID: number | undefined
// 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)
@ -240,109 +240,109 @@ export default abstract class Observer {
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? )
this.unbindNode(node);
return false;
this.unbindNode(node)
return false
}
parentID = this.app.nodes.getID(parent);
parentID = this.app.nodes.getID(parent)
if (parentID === undefined) {
this.unbindNode(node);
return false;
this.unbindNode(node)
return false
}
if (!this.commitNode(parentID)) {
this.unbindNode(node);
return false;
this.unbindNode(node)
return false
}
this.app.sanitizer.handleNode(id, parentID, node);
this.app.sanitizer.handleNode(id, parentID, node)
if (this.app.sanitizer.isMaskedContainer(parentID)) {
return false;
return false
}
}
// From here parentID === undefined if node is top context HTML node
let sibling = node.previousSibling;
let sibling = node.previousSibling
while (sibling !== null) {
const siblingID = this.app.nodes.getID(sibling);
const siblingID = this.app.nodes.getID(sibling)
if (siblingID !== undefined) {
this.commitNode(siblingID);
this.indexes[id] = this.indexes[siblingID] + 1;
break;
this.commitNode(siblingID)
this.indexes[id] = this.indexes[siblingID] + 1
break
}
sibling = sibling.previousSibling;
sibling = sibling.previousSibling
}
if (sibling === null) {
this.indexes[id] = 0;
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';
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;
const height = el.clientHeight;
el = node.cloneNode() as Element;
(el as HTMLElement | SVGElement).style.width = width + 'px';
(el as HTMLElement | SVGElement).style.height = height + 'px';
const width = el.clientWidth
const height = el.clientHeight
el = node.cloneNode() as Element
;(el as HTMLElement | SVGElement).style.width = width + 'px'
;(el as HTMLElement | SVGElement).style.height = height + 'px'
}
this.app.send(CreateElementNode(id, parentID, index, el.tagName, isSVGElement(node)));
this.app.send(CreateElementNode(id, parentID, index, el.tagName, isSVGElement(node)))
}
for (let i = 0; i < el.attributes.length; i++) {
const attr = el.attributes[i];
this.sendNodeAttribute(id, el, attr.nodeName, attr.value);
const attr = el.attributes[i]
this.sendNodeAttribute(id, el, attr.nodeName, attr.value)
}
} else if (isTextNode(node)) {
// for text node id != 0, hence parentID !== undefined and parent is Element
this.app.send(CreateTextNode(id, parentID as number, index));
this.sendNodeData(id, parent as Element, node.data);
this.app.send(CreateTextNode(id, parentID as number, index))
this.sendNodeData(id, parent as Element, node.data)
}
return true;
return true
}
if (recentsType === RecentsType.Removed && parentID !== undefined) {
this.app.send(MoveNode(id, parentID, index));
this.app.send(MoveNode(id, parentID, index))
}
const attr = this.attributesMap.get(id);
const attr = this.attributesMap.get(id)
if (attr !== undefined) {
if (!isElementNode(node)) {
throw 'commitNode: node is not an element';
throw 'commitNode: node is not an element'
}
for (const name of attr) {
this.sendNodeAttribute(id, node, name, node.getAttribute(name));
this.sendNodeAttribute(id, node, name, node.getAttribute(name))
}
}
if (this.textSet.has(id)) {
if (!isTextNode(node)) {
throw 'commitNode: node is not a text';
throw 'commitNode: node is not a text'
}
// for text node id != 0, hence parent is Element
this.sendNodeData(id, parent as Element, node.data);
this.sendNodeData(id, parent as Element, node.data)
}
return true;
return true
}
private commitNode(id: number): boolean {
const node = this.app.nodes.getNode(id);
const node = this.app.nodes.getNode(id)
if (node === undefined) {
return false;
return false
}
const cmt = this.commited[id];
const cmt = this.commited[id]
if (cmt !== undefined) {
return cmt;
return cmt
}
return (this.commited[id] = this._commitNode(id, node));
return (this.commited[id] = this._commitNode(id, node))
}
private commitNodes(isStart = false): void {
let node;
let node
this.recents.forEach((type, id) => {
this.commitNode(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();
})
this.clear()
}
// ISSSUE
@ -358,14 +358,14 @@ export default abstract class Observer {
subtree: true,
attributeOldValue: false,
characterDataOldValue: false,
});
this.bindTree(nodeToBind);
beforeCommit(this.app.nodes.getID(node));
this.commitNodes(true);
})
this.bindTree(nodeToBind)
beforeCommit(this.app.nodes.getID(node))
this.commitNodes(true)
}
disconnect(): void {
this.observer.disconnect();
this.clear();
this.observer.disconnect()
this.clear()
}
}

View file

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

View file

@ -1,29 +1,29 @@
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 '../messages.gen.js';
import App from '../index.js';
import { IN_BROWSER, hasOpenreplayAttribute } from '../../utils.js';
import { CreateDocument } from '../messages.gen.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 {
private readonly options: Options;
private readonly options: Options
constructor(app: App, options: Partial<Options>) {
super(app, true);
super(app, true)
this.options = Object.assign(
{
captureIFrames: true,
},
options,
);
)
// IFrames
this.app.nodes.attachNodeCallback((node) => {
@ -32,59 +32,59 @@ export default class TopObserver extends Observer {
((this.options.captureIFrames && !hasOpenreplayAttribute(node, 'obscured')) ||
hasOpenreplayAttribute(node, 'capture'))
) {
this.handleIframe(node);
this.handleIframe(node)
}
});
})
// ShadowDOM
this.app.nodes.attachNodeCallback((node) => {
if (isElementNode(node) && node.shadowRoot !== null) {
this.handleShadowRoot(node.shadowRoot);
this.handleShadowRoot(node.shadowRoot)
}
});
})
}
private iframeObservers: IFrameObserver[] = [];
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);
const id = this.app.nodes.getID(iframe)
if (id === undefined) {
return;
return
} //log
if (iframe.contentDocument === doc) {
return;
return
} // How frequently can it happen?
doc = iframe.contentDocument;
doc = iframe.contentDocument
if (!doc || !iframe.contentWindow) {
return;
return
}
const observer = new IFrameObserver(this.app);
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;
const observer = this
Element.prototype.attachShadow = function () {
// eslint-disable-next-line
const shadow = attachShadowNativeFn.apply(this, arguments);
observer.handleShadowRoot(shadow);
return shadow;
};
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>
@ -95,18 +95,18 @@ export default class TopObserver extends Observer {
this.observeRoot(
window.document,
() => {
this.app.send(CreateDocument());
this.app.send(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,16 +1,16 @@
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;
obscureTextNumbers: boolean;
obscureTextEmails: boolean
obscureTextNumbers: boolean
}
export default class Sanitizer {
private readonly masked: Set<number> = new Set();
private readonly maskedContainers: Set<number> = new Set();
private readonly options: Options;
private readonly masked: Set<number> = new Set()
private readonly maskedContainers: Set<number> = new Set()
private readonly options: Options
constructor(private readonly app: App, options: Partial<Options>) {
this.options = Object.assign(
@ -19,7 +19,7 @@ export default class Sanitizer {
obscureTextNumbers: false,
},
options,
);
)
}
handleNode(id: number, parentID: number, node: Node) {
@ -27,13 +27,13 @@ export default class Sanitizer {
this.masked.has(parentID) ||
(isElementNode(node) && hasOpenreplayAttribute(node, 'masked'))
) {
this.masked.add(id);
this.masked.add(id)
}
if (
this.maskedContainers.has(parentID) ||
(isElementNode(node) && hasOpenreplayAttribute(node, 'htmlmasked'))
) {
this.maskedContainers.add(id);
this.maskedContainers.add(id)
}
}
@ -42,40 +42,37 @@ export default class Sanitizer {
// 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,
'█',
);
.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');
data = data.replace(/\d/g, '0')
}
if (this.options.obscureTextEmails) {
data = data.replace(
/([^\s]+)@([^\s]+)\.([^\s]+)/g,
(...f: Array<string>) => stars(f[1]) + '@' + stars(f[2]) + '.' + stars(f[3]),
);
)
}
return data;
return data
}
isMasked(id: number): boolean {
return this.masked.has(id);
return this.masked.has(id)
}
isMaskedContainer(id: number) {
return this.maskedContainers.has(id);
return this.maskedContainers.has(id)
}
getInnerTextSecure(el: HTMLElement): string {
const id = this.app.nodes.getID(el);
const id = this.app.nodes.getID(el)
if (!id) {
return '';
return ''
}
return this.sanitize(id, el.innerText);
return this.sanitize(id, el.innerText)
}
clear(): void {
this.masked.clear();
this.maskedContainers.clear();
this.masked.clear()
this.maskedContainers.clear()
}
}

View file

@ -1,50 +1,50 @@
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 readonly 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;
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 {
@ -52,12 +52,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,31 +1,31 @@
import App from './index.js';
import App from './index.js'
type Callback = () => void;
type Callback = () => void
function wrap(callback: Callback, n: number): Callback {
let t = 0;
let t = 0
return (): void => {
if (t++ >= n) {
t = 0;
callback();
t = 0
callback()
}
};
}
}
export default class Ticker {
private timer: ReturnType<typeof setInterval> | null = null;
private readonly callbacks: Array<Callback | undefined>;
private timer: ReturnType<typeof setInterval> | null = null
private readonly callbacks: Array<Callback | undefined>
constructor(private readonly app: App) {
this.callbacks = [];
this.callbacks = []
}
attach(callback: Callback, n = 0, useSafe = true, thisArg?: any) {
if (thisArg) {
callback = callback.bind(thisArg);
callback = callback.bind(thisArg)
}
if (useSafe) {
callback = this.app.safe(callback);
callback = this.app.safe(callback)
}
this.callbacks.unshift(n ? wrap(callback, n) : callback) - 1;
this.callbacks.unshift(n ? wrap(callback, n) : callback) - 1
}
start(): void {
@ -33,17 +33,17 @@ export default class Ticker {
this.timer = setInterval(
() =>
this.callbacks.forEach((cb) => {
if (cb) cb();
if (cb) cb()
}),
30,
);
)
}
}
stop(): void {
if (this.timer !== null) {
clearInterval(this.timer);
this.timer = null;
clearInterval(this.timer)
this.timer = null
}
}
}

View file

@ -1,56 +1,56 @@
import App, { DEFAULT_INGEST_POINT } from './app/index.js';
export { default as App } from './app/index.js';
import App, { DEFAULT_INGEST_POINT } from './app/index.js'
export { default as App } from './app/index.js'
import { UserID, UserAnonymousID, RawCustomEvent, CustomIssue } from './app/messages.gen.js';
import * as _Messages from './app/messages.gen.js';
export const Messages = _Messages;
import { UserID, UserAnonymousID, RawCustomEvent, CustomIssue } from './app/messages.gen.js'
import * as _Messages from './app/messages.gen.js'
export const Messages = _Messages
import Connection from './modules/connection.js';
import Console from './modules/console.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';
} 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';
import type { StartPromiseReturn } from './app/index.js'
export type Options = Partial<
AppOptions & ConsoleOptions & ExceptionOptions & InputOptions & PerformanceOptions & TimingOptions
> & {
projectID?: number; // For the back compatibility only (deprecated)
projectKey: string;
sessionToken?: string;
respectDoNotTrack?: boolean;
autoResetOnWindowOpen?: boolean;
projectID?: number // For the back compatibility only (deprecated)
projectKey: string
sessionToken?: string
respectDoNotTrack?: boolean
autoResetOnWindowOpen?: boolean
// dev only
__DISABLE_SECURE_MODE?: boolean;
};
__DISABLE_SECURE_MODE?: boolean
}
const DOCS_SETUP = '/installation/setup-or';
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}`,
);
return false;
)
return false
}
if (typeof obj.projectKey !== 'string') {
if (typeof obj.projectKey !== 'number') {
@ -58,46 +58,46 @@ function processOptions(obj: any): obj is Options {
// 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;
)
return false
} else {
obj.projectKey = obj.projectID.toString();
deprecationWarn('`projectID` option', '`projectKey` option', DOCS_SETUP);
obj.projectKey = obj.projectID.toString()
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}`,
);
)
}
return true;
return true
}
export default class API {
private readonly app: App | null = null;
private readonly app: App | null = null
constructor(private readonly options: Options) {
if (!IN_BROWSER || !processOptions(options)) {
return;
return
}
if ((window as any).__OPENREPLAY__) {
console.error('OpenReplay: one tracker instance has been initialised already');
return;
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.',
);
return;
)
return
}
const doNotTrack =
options.respectDoNotTrack &&
(navigator.doNotTrack == '1' ||
// @ts-ignore
window.doNotTrack == '1');
window.doNotTrack == '1')
const app = (this.app =
doNotTrack ||
!('Map' in window) ||
@ -109,42 +109,42 @@ 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);
Connection(app);
Console(app, options);
Exception(app, options);
Img(app);
Input(app, options);
Mouse(app);
Timing(app, options);
Performance(app, options);
Scroll(app);
(window as any).__OPENREPLAY__ = this;
Viewport(app)
CSSRules(app)
Connection(app)
Console(app, options)
Exception(app, options)
Img(app)
Input(app, options)
Mouse(app)
Timing(app, options)
Performance(app, options)
Scroll(app)
;(window as any).__OPENREPLAY__ = this
if (options.autoResetOnWindowOpen) {
const wOpen = window.open;
const wOpen = window.open
app.attachStartCallback(() => {
// @ts-ignore ?
window.open = function (...args) {
app.resetNextPageSession(true);
wOpen.call(window, ...args);
app.resetNextPageSession(false);
};
});
app.resetNextPageSession(true)
wOpen.call(window, ...args)
app.resetNextPageSession(false)
}
})
app.attachStopCallback(() => {
window.open = wOpen;
});
window.open = wOpen
})
}
} else {
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');
)
const req = new XMLHttpRequest()
const orig = options.ingestPoint || DEFAULT_INGEST_POINT
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(
@ -154,99 +154,99 @@ export default class API {
doNotTrack,
// TODO: add precise reason (an exact API missing)
}),
);
)
}
}
use<T>(fn: (app: App | null, options?: Options) => T): T {
return fn(this.app, this.options);
return fn(this.app, this.options)
}
isActive(): boolean {
if (this.app === null) {
return false;
return false
}
return this.app.active();
return this.app.active()
}
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.');
)
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.");
return Promise.reject("Browser doesn't support required api, or doNotTrack is active.")
}
// TODO: check argument type
return this.app.start(startOpts);
return this.app.start(startOpts)
}
stop(): void {
if (this.app === null) {
return;
return
}
this.app.stop(true);
this.app.stop(true)
}
getSessionToken(): string | null | undefined {
if (this.app === null) {
return null;
return null
}
return this.app.getSessionToken();
return this.app.getSessionToken()
}
getSessionID(): string | null | undefined {
if (this.app === null) {
return null;
return null
}
return this.app.getSessionID();
return this.app.getSessionID()
}
sessionID(): string | null | undefined {
deprecationWarn("'sessionID' method", "'getSessionID' method", '/');
return this.getSessionID();
deprecationWarn("'sessionID' method", "'getSessionID' method", '/')
return this.getSessionID()
}
setUserID(id: string): void {
if (typeof id === 'string' && this.app !== null) {
this.app.session.setUserID(id);
this.app.session.setUserID(id)
}
}
userID(id: string): void {
deprecationWarn("'userID' method", "'setUserID' method", '/');
this.setUserID(id);
deprecationWarn("'userID' method", "'setUserID' method", '/')
this.setUserID(id)
}
setUserAnonymousID(id: string): void {
if (typeof id === 'string' && this.app !== null) {
this.app.send(UserAnonymousID(id));
this.app.send(UserAnonymousID(id))
}
}
userAnonymousID(id: string): void {
deprecationWarn("'userAnonymousID' method", "'setUserAnonymousID' method", '/');
this.setUserAnonymousID(id);
deprecationWarn("'userAnonymousID' method", "'setUserAnonymousID' method", '/')
this.setUserAnonymousID(id)
}
setMetadata(key: string, value: string): void {
if (typeof key === 'string' && typeof value === 'string' && this.app !== null) {
this.app.session.setMetadata(key, value);
this.app.session.setMetadata(key, value)
}
}
metadata(key: string, value: string): void {
deprecationWarn("'metadata' method", "'setMetadata' method", '/');
this.setMetadata(key, value);
deprecationWarn("'metadata' method", "'setMetadata' method", '/')
this.setMetadata(key, value)
}
event(key: string, payload: any, issue = false): void {
if (typeof key === 'string' && this.app !== null) {
if (issue) {
return this.issue(key, payload);
return this.issue(key, payload)
} else {
try {
payload = JSON.stringify(payload);
payload = JSON.stringify(payload)
} catch (e) {
return;
return
}
this.app.send(RawCustomEvent(key, payload));
this.app.send(RawCustomEvent(key, payload))
}
}
}
@ -254,28 +254,28 @@ export default class API {
issue(key: string, payload: any): void {
if (typeof key === 'string' && this.app !== null) {
try {
payload = JSON.stringify(payload);
payload = JSON.stringify(payload)
} catch (e) {
return;
return
}
this.app.send(CustomIssue(key, payload));
this.app.send(CustomIssue(key, payload))
}
}
handleError = (e: Error | ErrorEvent | PromiseRejectionEvent) => {
if (this.app === null) {
return;
return
}
if (e instanceof Error) {
this.app.send(getExceptionMessage(e, []));
this.app.send(getExceptionMessage(e, []))
} else if (
e instanceof ErrorEvent ||
('PromiseRejectionEvent' in window && e instanceof PromiseRejectionEvent)
) {
const msg = getExceptionMessageFromEvent(e);
const msg = getExceptionMessageFromEvent(e)
if (msg != null) {
this.app.send(msg);
this.app.send(msg)
}
}
};
}
}

View file

@ -1,25 +1,25 @@
import App from '../app/index.js';
import { ConnectionInformation } from '../app/messages.gen.js';
import App from '../app/index.js'
import { ConnectionInformation } from '../app/messages.gen.js'
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 ||
(navigator as any).webkitConnection;
(navigator as any).webkitConnection
if (connection === undefined) {
return;
return
}
const sendConnectionInformation = (): void =>
app.send(
ConnectionInformation(Math.round(connection.downlink * 1000), connection.type || 'unknown'),
);
sendConnectionInformation();
connection.addEventListener('change', sendConnectionInformation);
)
sendConnectionInformation()
connection.addEventListener('change', sendConnectionInformation)
}

View file

@ -1,100 +1,100 @@
import type App from '../app/index.js';
import { hasTag } from '../app/guards.js';
import { IN_BROWSER } from '../utils.js';
import { ConsoleLog } from '../app/messages.gen.js';
import type App from '../app/index.js'
import { hasTag } from '../app/guards.js'
import { IN_BROWSER } from '../utils.js'
import { ConsoleLog } from '../app/messages.gen.js'
const printError: (e: Error) => string =
IN_BROWSER && 'InstallTrigger' in window // detect Firefox
? (e: Error): string => e.message + '\n' + e.stack
: (e: Error): string => e.stack || e.message;
: (e: Error): string => e.stack || e.message
function printString(arg: any): string {
if (arg === undefined) {
return 'undefined';
return 'undefined'
}
if (arg === null) {
return 'null';
return 'null'
}
if (arg instanceof Error) {
return printError(arg);
return printError(arg)
}
if (Array.isArray(arg)) {
return `Array(${arg.length})`;
return `Array(${arg.length})`
}
return String(arg);
return String(arg)
}
function printFloat(arg: any): string {
if (typeof arg !== 'number') return 'NaN';
return arg.toString();
if (typeof arg !== 'number') return 'NaN'
return arg.toString()
}
function printInt(arg: any): string {
if (typeof arg !== 'number') return 'NaN';
return Math.floor(arg).toString();
if (typeof arg !== 'number') return 'NaN'
return Math.floor(arg).toString()
}
function printObject(arg: any): string {
if (arg === undefined) {
return 'undefined';
return 'undefined'
}
if (arg === null) {
return 'null';
return 'null'
}
if (arg instanceof Error) {
return printError(arg);
return printError(arg)
}
if (Array.isArray(arg)) {
const length = arg.length;
const values = arg.slice(0, 10).map(printString).join(', ');
return `Array(${length})[${values}]`;
const length = arg.length
const values = arg.slice(0, 10).map(printString).join(', ')
return `Array(${length})[${values}]`
}
if (typeof arg === 'object') {
const res = [];
let i = 0;
const res = []
let i = 0
for (const k in arg) {
if (++i === 10) {
break;
break
}
const v = arg[k];
res.push(k + ': ' + printString(v));
const v = arg[k]
res.push(k + ': ' + printString(v))
}
return '{' + res.join(', ') + '}';
return '{' + res.join(', ') + '}'
}
return arg.toString();
return arg.toString()
}
function printf(args: any[]): string {
if (typeof args[0] === 'string') {
args.unshift(
args.shift().replace(/%(o|s|f|d|i)/g, (s: string, t: string): string => {
const arg = args.shift();
if (arg === undefined) return s;
const arg = args.shift()
if (arg === undefined) return s
switch (t) {
case 'o':
return printObject(arg);
return printObject(arg)
case 's':
return printString(arg);
return printString(arg)
case 'f':
return printFloat(arg);
return printFloat(arg)
case 'd':
case 'i':
return printInt(arg);
return printInt(arg)
default:
return s;
return s
}
}),
);
)
}
return args.map(printObject).join(' ');
return args.map(printObject).join(' ')
}
export interface Options {
consoleMethods: Array<string> | null;
consoleThrottling: number;
consoleMethods: Array<string> | null
consoleThrottling: number
}
const consoleMethods = ['log', 'info', 'warn', 'error', 'debug', 'assert'];
const consoleMethods = ['log', 'info', 'warn', 'error', 'debug', 'assert']
export default function (app: App, opts: Partial<Options>): void {
const options: Options = Object.assign(
@ -103,54 +103,54 @@ export default function (app: App, opts: Partial<Options>): void {
consoleThrottling: 30,
},
opts,
);
)
if (!Array.isArray(options.consoleMethods) || options.consoleMethods.length === 0) {
return;
return
}
const sendConsoleLog = app.safe((level: string, args: unknown[]): void =>
app.send(ConsoleLog(level, printf(args))),
);
)
let n: number;
let n: number
const reset = (): void => {
n = 0;
};
app.attachStartCallback(reset);
app.ticker.attach(reset, 33, false);
n = 0
}
app.attachStartCallback(reset)
app.ticker.attach(reset, 33, false)
const patchConsole = (console: Console) =>
options.consoleMethods!.forEach((method) => {
if (consoleMethods.indexOf(method) === -1) {
console.error(`OpenReplay: unsupported console method "${method}"`);
return;
console.error(`OpenReplay: unsupported console method "${method}"`)
return
}
const fn = (console as any)[method];
(console as any)[method] = function (...args: unknown[]): void {
fn.apply(this, args);
const fn = (console as any)[method]
;(console as any)[method] = function (...args: unknown[]): void {
fn.apply(this, args)
if (n++ > options.consoleThrottling) {
return;
return
}
sendConsoleLog(method, args);
};
});
patchConsole(window.console);
sendConsoleLog(method, args)
}
})
patchConsole(window.console)
app.nodes.attachNodeCallback(
app.safe((node) => {
if (hasTag(node, 'IFRAME')) {
// TODO: newContextCallback
let context = node.contentWindow;
let context = node.contentWindow
if (context) {
patchConsole((context as Window & typeof globalThis).console);
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);
context = node.contentWindow
patchConsole((context as Window & typeof globalThis).console)
}
});
})
}
}),
);
)
}

View file

@ -1,14 +1,14 @@
import type App from '../app/index.js';
import { CSSInsertRuleURLBased, CSSDeleteRule, TechnicalInfo } from '../app/messages.gen.js';
import { hasTag } from '../app/guards.js';
import type App from '../app/index.js'
import { CSSInsertRuleURLBased, CSSDeleteRule, TechnicalInfo } from '../app/messages.gen.js'
import { hasTag } from '../app/guards.js'
export default function (app: App | null) {
if (app === null) {
return;
return
}
if (!window.CSSStyleSheet) {
app.send(TechnicalInfo('no_stylesheet_prototype_in_window', ''));
return;
app.send(TechnicalInfo('no_stylesheet_prototype_in_window', ''))
return
}
const processOperation = app.safe((stylesheet: CSSStyleSheet, index: number, rule?: string) => {
@ -16,38 +16,38 @@ export default function (app: App | null) {
typeof rule === 'string'
? (nodeID: number) =>
app.send(CSSInsertRuleURLBased(nodeID, rule, index, app.getBaseHref()))
: (nodeID: number) => app.send(CSSDeleteRule(nodeID, index));
: (nodeID: number) => app.send(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');
throw new Error('Owner Node not found')
}
const nodeID = app.nodes.getID(stylesheet.ownerNode);
const nodeID = app.nodes.getID(stylesheet.ownerNode)
if (nodeID !== undefined) {
sendMessage(nodeID);
sendMessage(nodeID)
} // else error?
});
})
const { insertRule, deleteRule } = CSSStyleSheet.prototype;
const { insertRule, deleteRule } = CSSStyleSheet.prototype
CSSStyleSheet.prototype.insertRule = function (rule: string, index = 0) {
processOperation(this, index, rule);
return insertRule.call(this, rule, index);
};
processOperation(this, index, rule)
return insertRule.call(this, rule, index)
}
CSSStyleSheet.prototype.deleteRule = function (index: number) {
processOperation(this, index);
return deleteRule.call(this, index);
};
processOperation(this, index)
return deleteRule.call(this, index)
}
app.nodes.attachNodeCallback((node: Node): void => {
if (!hasTag(node, 'STYLE') || !node.sheet) {
return;
return
}
if (node.textContent !== null && node.textContent.trim().length > 0) {
return; // Only fully virtual sheets maintained so far
return // Only fully virtual sheets maintained so far
}
const rules = node.sheet.cssRules;
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,18 +1,18 @@
import type App from '../app/index.js';
import type Message from '../app/messages.gen.js';
import { JSException } from '../app/messages.gen.js';
import ErrorStackParser from 'error-stack-parser';
import type App from '../app/index.js'
import type Message from '../app/messages.gen.js'
import { JSException } from '../app/messages.gen.js'
import ErrorStackParser from 'error-stack-parser'
export interface Options {
captureExceptions: boolean;
captureExceptions: boolean
}
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> {
@ -24,15 +24,15 @@ function getDefaultStack(e: ErrorEvent): Array<StackFrame> {
functionName: '',
source: '',
},
];
]
}
export function getExceptionMessage(error: Error, fallbackStack: Array<StackFrame>): Message {
let stack = fallbackStack;
let stack = fallbackStack
try {
stack = ErrorStackParser.parse(error);
stack = ErrorStackParser.parse(error)
} catch (e) {}
return JSException(error.name, error.message, JSON.stringify(stack));
return JSException(error.name, error.message, JSON.stringify(stack))
}
export function getExceptionMessageFromEvent(
@ -40,29 +40,29 @@ export function getExceptionMessageFromEvent(
): 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(':');
let [name, message] = e.message.split(':')
if (!message) {
name = 'Error';
message = e.message;
name = 'Error'
message = e.message
}
return JSException(name, message, JSON.stringify(getDefaultStack(e)));
return 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;
let message: string
try {
message = JSON.stringify(e.reason);
message = JSON.stringify(e.reason)
} catch (_) {
message = String(e.reason);
message = String(e.reason)
}
return JSException('Unhandled Promise Rejection', message, '[]');
return JSException('Unhandled Promise Rejection', message, '[]')
}
}
return null;
return null
}
export default function (app: App, opts: Partial<Options>): void {
@ -71,18 +71,18 @@ export default function (app: App, opts: Partial<Options>): void {
captureExceptions: true,
},
opts,
);
)
if (options.captureExceptions) {
const handler = (e: ErrorEvent | PromiseRejectionEvent): void => {
const msg = getExceptionMessageFromEvent(e);
const msg = getExceptionMessageFromEvent(e)
if (msg != null) {
app.send(msg);
app.send(msg)
}
};
}
app.attachEventListener(window, 'unhandledrejection', (e: PromiseRejectionEvent): void =>
handler(e),
);
app.attachEventListener(window, 'error', (e: ErrorEvent): void => handler(e));
)
app.attachEventListener(window, 'error', (e: ErrorEvent): void => handler(e))
}
}

View file

@ -1,92 +1,92 @@
import type App from '../app/index.js';
import { timestamp, isURL } from '../utils.js';
import { ResourceTiming, SetNodeAttributeURLBased, SetNodeAttribute } from '../app/messages.gen.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 '../app/messages.gen.js'
import { hasTag } from '../app/guards.js'
function resolveURL(url: string, location: Location = document.location) {
url = url.trim();
url = url.trim()
if (url.startsWith('/')) {
return location.origin + url;
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(SetNodeAttribute(id, 'src', PLACEHOLDER_SRC));
const { width, height } = node.getBoundingClientRect();
app.send(SetNodeAttribute(id, 'src', PLACEHOLDER_SRC))
const { width, height } = node.getBoundingClientRect()
if (!node.hasAttribute('width')) {
app.send(SetNodeAttribute(id, 'width', String(width)));
app.send(SetNodeAttribute(id, 'width', String(width)))
}
if (!node.hasAttribute('height')) {
app.send(SetNodeAttribute(id, 'height', String(height)));
app.send(SetNodeAttribute(id, 'height', String(height)))
}
}
const sendImgSrc = app.safe(function (this: HTMLImageElement): void {
const id = app.nodes.getID(this);
const id = app.nodes.getID(this)
if (id === undefined) {
return;
return
}
const { src, complete, naturalWidth, naturalHeight, srcset } = this;
const { src, complete, naturalWidth, naturalHeight, srcset } = this
if (!complete) {
return;
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(ResourceTiming(timestamp(), 0, 0, 0, 0, 0, resolvedSrc, 'img'));
app.send(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(SetNodeAttribute(id, 'src', resolvedSrc));
app.send(SetNodeAttribute(id, 'src', resolvedSrc))
if (srcset) {
const resolvedSrcset = srcset
.split(',')
.map((str) => resolveURL(str))
.join(',');
app.send(SetNodeAttribute(id, 'srcset', resolvedSrcset));
.join(',')
app.send(SetNodeAttribute(id, 'srcset', resolvedSrcset))
}
}
});
})
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'attributes') {
const target = mutation.target as HTMLImageElement;
const id = app.nodes.getID(target);
const target = mutation.target as HTMLImageElement
const id = app.nodes.getID(target)
if (id === undefined) {
return;
return
}
if (mutation.attributeName === 'src') {
const src = target.src;
app.send(SetNodeAttributeURLBased(id, 'src', src, app.getBaseHref()));
const src = target.src
app.send(SetNodeAttributeURLBased(id, 'src', src, app.getBaseHref()))
}
if (mutation.attributeName === 'srcset') {
const srcset = target.srcset;
app.send(SetNodeAttribute(id, 'srcset', srcset));
const srcset = target.srcset
app.send(SetNodeAttribute(id, 'srcset', srcset))
}
}
}
});
})
app.nodes.attachNodeCallback((node: Node): void => {
if (!hasTag(node, 'IMG')) {
return;
return
}
app.nodes.attachElementListener('error', node, sendImgSrc);
app.nodes.attachElementListener('load', node, sendImgSrc);
sendImgSrc.call(node);
observer.observe(node, { attributes: true, attributeFilter: ['src', 'srcset'] });
});
app.nodes.attachElementListener('error', node, sendImgSrc)
app.nodes.attachElementListener('load', node, sendImgSrc)
sendImgSrc.call(node)
observer.observe(node, { attributes: true, attributeFilter: ['src', 'srcset'] })
})
}

View file

@ -1,74 +1,74 @@
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 '../app/messages.gen.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 '../app/messages.gen.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')) {
return true;
return true
}
if (!hasTag(node, 'INPUT')) {
return false;
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')) {
return false;
return false
}
const type = node.type;
return type === 'checkbox' || type === 'radio';
const type = node.type
return type === 'checkbox' || type === 'radio'
}
const labelElementFor: (element: TextEditableElement) => HTMLLabelElement | undefined =
IN_BROWSER && 'labels' in HTMLInputElement.prototype
? (node) => {
let p: Node | null = node;
let p: Node | null = node
while ((p = p.parentNode) !== null) {
if (hasTag(p, 'LABEL')) {
return p;
return p
}
}
const labels = node.labels;
const labels = node.labels
if (labels !== null && labels.length === 1) {
return labels[0];
return labels[0]
}
}
: (node) => {
let p: Node | null = node;
let p: Node | null = node
while ((p = p.parentNode) !== null) {
if (hasTag(p, 'LABEL')) {
return p;
return p
}
}
const id = node.id;
const id = node.id
if (id) {
const labels = document.querySelectorAll('label[for="' + id + '"]');
const labels = document.querySelectorAll('label[for="' + id + '"]')
if (labels !== null && labels.length === 1) {
return labels[0] as HTMLLabelElement;
return labels[0] as HTMLLabelElement
}
}
};
}
export function getInputLabel(node: TextEditableElement): string {
let label = getLabelAttribute(node);
let label = getLabelAttribute(node)
if (label === null) {
const labelElement = labelElementFor(node);
const labelElement = labelElementFor(node)
label =
(labelElement && labelElement.innerText) ||
node.placeholder ||
node.name ||
node.id ||
node.className ||
node.type;
node.type
}
return normSpaces(label).slice(0, 100);
return normSpaces(label).slice(0, 100)
}
export declare const enum InputMode {
@ -78,10 +78,10 @@ export declare const enum InputMode {
}
export interface Options {
obscureInputNumbers: boolean;
obscureInputEmails: boolean;
defaultInputMode: InputMode;
obscureInputDates: boolean;
obscureInputNumbers: boolean
obscureInputEmails: boolean
defaultInputMode: InputMode
obscureInputDates: boolean
}
export default function (app: App, opts: Partial<Options>): void {
@ -93,18 +93,18 @@ export default function (app: App, opts: Partial<Options>): void {
obscureInputDates: false,
},
opts,
);
)
function sendInputTarget(id: number, node: TextEditableElement): void {
const label = getInputLabel(node);
const label = getInputLabel(node)
if (label !== '') {
app.send(SetInputTarget(id, label));
app.send(SetInputTarget(id, label))
}
}
function sendInputValue(id: number, node: TextEditableElement | HTMLSelectElement): void {
let value = node.value;
let inputMode: InputMode = options.defaultInputMode;
let value = node.value
let inputMode: InputMode = options.defaultInputMode
if (node.type === 'password' || hasOpenreplayAttribute(node, 'hidden')) {
inputMode = InputMode.Hidden;
inputMode = InputMode.Hidden
} else if (
hasOpenreplayAttribute(node, 'obscured') ||
(inputMode === InputMode.Plain &&
@ -112,88 +112,88 @@ export default function (app: App, opts: Partial<Options>): void {
(options.obscureInputDates && node.type === 'date') ||
(options.obscureInputEmails && (node.type === 'email' || !!~value.indexOf('@')))))
) {
inputMode = InputMode.Obscured;
inputMode = InputMode.Obscured
}
let mask = 0;
let mask = 0
switch (inputMode) {
case InputMode.Hidden:
mask = -1;
value = '';
break;
mask = -1
value = ''
break
case InputMode.Obscured:
mask = value.length;
value = '';
break;
mask = value.length
value = ''
break
}
app.send(SetInputValue(id, value, mask));
app.send(SetInputValue(id, value, mask))
}
const inputValues: Map<number, string> = new Map();
const checkableValues: Map<number, boolean> = new Map();
const registeredTargets: Set<number> = new Set();
const inputValues: Map<number, string> = new Map()
const checkableValues: Map<number, boolean> = new Map()
const registeredTargets: Set<number> = new Set()
app.attachStopCallback(() => {
inputValues.clear();
checkableValues.clear();
registeredTargets.clear();
});
inputValues.clear()
checkableValues.clear()
registeredTargets.clear()
})
app.ticker.attach((): void => {
inputValues.forEach((value, id) => {
const node = app.nodes.getNode(id);
if (!node) return;
const node = app.nodes.getNode(id)
if (!node) return
if (!isTextEditable(node)) {
inputValues.delete(id);
return;
inputValues.delete(id)
return
}
if (value !== node.value) {
inputValues.set(id, node.value);
inputValues.set(id, node.value)
if (!registeredTargets.has(id)) {
registeredTargets.add(id);
sendInputTarget(id, node);
registeredTargets.add(id)
sendInputTarget(id, node)
}
sendInputValue(id, node);
sendInputValue(id, node)
}
});
})
checkableValues.forEach((checked, id) => {
const node = app.nodes.getNode(id);
if (!node) return;
const node = app.nodes.getNode(id)
if (!node) return
if (!isCheckable(node)) {
checkableValues.delete(id);
return;
checkableValues.delete(id)
return
}
if (checked !== node.checked) {
checkableValues.set(id, node.checked);
app.send(SetInputChecked(id, node.checked));
checkableValues.set(id, node.checked)
app.send(SetInputChecked(id, node.checked))
}
});
});
app.ticker.attach(Set.prototype.clear, 100, false, registeredTargets);
})
})
app.ticker.attach(Set.prototype.clear, 100, false, registeredTargets)
app.nodes.attachNodeCallback(
app.safe((node: Node): void => {
const id = app.nodes.getID(node);
const id = app.nodes.getID(node)
if (id === undefined) {
return;
return
}
// TODO: support multiple select (?): use selectedOptions; Need send target?
if (hasTag(node, 'SELECT')) {
sendInputValue(id, node);
sendInputValue(id, node)
app.attachEventListener(node, 'change', () => {
sendInputValue(id, node);
});
sendInputValue(id, node)
})
}
if (isTextEditable(node)) {
inputValues.set(id, node.value);
sendInputValue(id, node);
return;
inputValues.set(id, node.value)
sendInputValue(id, node)
return
}
if (isCheckable(node)) {
checkableValues.set(id, node.checked);
app.send(SetInputChecked(id, node.checked));
return;
checkableValues.set(id, node.checked)
app.send(SetInputChecked(id, node.checked))
return
}
}),
);
)
}

View file

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

View file

@ -1,15 +1,15 @@
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 '../app/messages.gen.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 '../app/messages.gen.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
@ -17,17 +17,17 @@ function _getSelector(target: Element): string {
.map((cn) => cn.trim())
.filter((cn) => cn !== '')
.reduce((sel, cn) => `${sel}.${cn}`, el.tagName.toLowerCase()) +
(selector ? ` > ${selector}` : '');
(selector ? ` > ${selector}` : '')
if (el === document.body) {
return selector;
return selector
}
el = el.parentElement;
} while (el !== document.body && el !== null);
return selector;
el = el.parentElement
} while (el !== document.body && el !== null)
return selector
}
function isClickable(element: Element): boolean {
const tag = element.tagName.toUpperCase();
const tag = element.tagName.toUpperCase()
return (
tag === 'BUTTON' ||
tag === 'A' ||
@ -35,7 +35,7 @@ function isClickable(element: Element): boolean {
tag === 'SELECT' ||
(element as HTMLElement).onclick != null ||
element.getAttribute('role') === 'button'
);
)
//|| element.className.includes("btn")
// MBTODO: intersect addEventListener
}
@ -43,113 +43,113 @@ function isClickable(element: Element): boolean {
//TODO: fix (typescript doesn't allow work when the guard is inside the function)
function getTarget(target: EventTarget | null): Element | null {
if (target instanceof Element) {
return _getTarget(target);
return _getTarget(target)
}
return null;
return null
}
function _getTarget(target: Element): Element | null {
let element: Element | null = target;
let element: Element | null = target
while (element !== null && element !== document.documentElement) {
if (hasOpenreplayAttribute(element, 'masked')) {
return null;
return null
}
element = element.parentElement;
element = element.parentElement
}
if (isSVGElement(target)) {
let owner = target.ownerSVGElement;
let owner = target.ownerSVGElement
while (owner !== null) {
target = owner;
owner = owner.ownerSVGElement;
target = owner
owner = owner.ownerSVGElement
}
}
element = target;
element = target
while (element !== null && element !== document.documentElement) {
const tag = element.tagName.toUpperCase();
const tag = element.tagName.toUpperCase()
if (tag === 'LABEL') {
return null;
return null
}
if (tag === 'INPUT') {
return element;
return element
}
if (isClickable(element) || getLabelAttribute(element) !== null) {
return element;
return element
}
element = element.parentElement;
element = element.parentElement
}
return target === document.documentElement ? null : target;
return target === document.documentElement ? null : target
}
export default function (app: App): void {
function getTargetLabel(target: Element): string {
const dl = getLabelAttribute(target);
const dl = getLabelAttribute(target)
if (dl !== null) {
return dl;
return dl
}
if (hasTag(target, 'INPUT')) {
return getInputLabel(target);
return getInputLabel(target)
}
if (isClickable(target)) {
let label = '';
let label = ''
if (target instanceof HTMLElement) {
label = app.sanitizer.getInnerTextSecure(target);
label = app.sanitizer.getInnerTextSecure(target)
}
label = label || target.id || target.className;
return normSpaces(label).slice(0, 100);
label = label || target.id || target.className
return normSpaces(label).slice(0, 100)
}
return '';
return ''
}
let mousePositionX = -1;
let mousePositionY = -1;
let mousePositionChanged = false;
let mouseTarget: Element | null = null;
let mouseTargetTime = 0;
let mousePositionX = -1
let mousePositionY = -1
let mousePositionChanged = false
let mouseTarget: Element | null = null
let mouseTargetTime = 0
app.attachStopCallback(() => {
mousePositionX = -1;
mousePositionY = -1;
mousePositionChanged = false;
mouseTarget = null;
});
mousePositionX = -1
mousePositionY = -1
mousePositionChanged = false
mouseTarget = null
})
const sendMouseMove = (): void => {
if (mousePositionChanged) {
app.send(MouseMove(mousePositionX, mousePositionY));
mousePositionChanged = false;
app.send(MouseMove(mousePositionX, mousePositionY))
mousePositionChanged = false
}
};
}
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);
const target = getTarget(e.target)
if (target !== mouseTarget) {
mouseTarget = target;
mouseTargetTime = performance.now();
mouseTarget = target
mouseTargetTime = performance.now()
}
});
})
app.attachEventListener(
document,
'mousemove',
(e: MouseEvent): void => {
mousePositionX = e.clientX;
mousePositionY = e.clientY;
mousePositionChanged = true;
mousePositionX = e.clientX
mousePositionY = e.clientY
mousePositionChanged = true
},
false,
);
)
app.attachEventListener(document, 'click', (e: MouseEvent): void => {
const target = getTarget(e.target);
const target = getTarget(e.target)
if ((!e.clientX && !e.clientY) || target === null) {
return;
return
}
const id = app.nodes.getID(target);
const id = app.nodes.getID(target)
if (id !== undefined) {
sendMouseMove();
sendMouseMove()
app.send(
MouseClick(
id,
@ -158,10 +158,10 @@ export default function (app: App): void {
getSelector(id, target),
),
true,
);
)
}
mouseTarget = null;
});
mouseTarget = null
})
app.ticker.attach(sendMouseMove, 10);
app.ticker.attach(sendMouseMove, 10)
}

View file

@ -1,25 +1,25 @@
import type App from '../app/index.js';
import { IN_BROWSER } from '../utils.js';
import { PerformanceTrack } from '../app/messages.gen.js';
import type App from '../app/index.js'
import { IN_BROWSER } from '../utils.js'
import { PerformanceTrack } from '../app/messages.gen.js'
type Perf = {
memory: {
totalJSHeapSize?: number;
usedJSHeapSize?: number;
jsHeapSizeLimit?: number;
};
};
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: {} }
export const deviceMemory = IN_BROWSER ? ((navigator as any).deviceMemory || 0) * 1024 : 0;
export const jsHeapSizeLimit = perf.memory.jsHeapSizeLimit || 0;
export const deviceMemory = IN_BROWSER ? ((navigator as any).deviceMemory || 0) * 1024 : 0
export const jsHeapSizeLimit = perf.memory.jsHeapSizeLimit || 0
export interface Options {
capturePerformance: boolean;
capturePerformance: boolean
}
export default function (app: App, opts: Partial<Options>): void {
@ -28,36 +28,36 @@ export default function (app: App, opts: Partial<Options>): void {
capturePerformance: true,
},
opts,
);
)
if (!options.capturePerformance) {
return;
return
}
let frames: number | undefined;
let ticks: number | undefined;
let frames: number | undefined
let ticks: number | undefined
const nextFrame = (): void => {
if (frames === undefined || frames === -1) {
return;
return
}
frames++;
requestAnimationFrame(nextFrame);
};
frames++
requestAnimationFrame(nextFrame)
}
app.ticker.attach(
(): void => {
if (ticks === undefined || ticks === -1) {
return;
return
}
ticks++;
ticks++
},
0,
false,
);
)
const sendPerformanceTrack = (): void => {
if (frames === undefined || ticks === undefined) {
return;
return
}
app.send(
PerformanceTrack(
@ -66,21 +66,21 @@ export default function (app: App, opts: Partial<Options>): void {
perf.memory.totalJSHeapSize || 0,
perf.memory.usedJSHeapSize || 0,
),
);
ticks = frames = document.hidden ? -1 : 0;
};
)
ticks = frames = document.hidden ? -1 : 0
}
app.attachStartCallback((): void => {
ticks = frames = -1;
sendPerformanceTrack();
nextFrame();
});
ticks = frames = -1
sendPerformanceTrack()
nextFrame()
})
app.attachStopCallback((): void => {
ticks = frames = undefined;
});
ticks = frames = undefined
})
app.ticker.attach(sendPerformanceTrack, 40, false);
app.ticker.attach(sendPerformanceTrack, 40, false)
if (document.hidden !== undefined) {
app.attachEventListener(
@ -89,6 +89,6 @@ export default function (app: App, opts: Partial<Options>): void {
sendPerformanceTrack as EventListener,
false,
false,
);
)
}
}

View file

@ -1,10 +1,10 @@
import type App from '../app/index.js';
import { SetViewportScroll, SetNodeScroll } from '../app/messages.gen.js';
import { isElementNode } from '../app/guards.js';
import type App from '../app/index.js'
import { SetViewportScroll, SetNodeScroll } from '../app/messages.gen.js'
import { isElementNode } from '../app/guards.js'
export default function (app: App): void {
let documentScroll = false;
const nodeScroll: Map<Element, [number, number]> = new Map();
let documentScroll = false
const nodeScroll: Map<Element, [number, number]> = new Map()
const sendSetViewportScroll = app.safe((): void =>
app.send(
@ -19,49 +19,49 @@ export default function (app: App): void {
0,
),
),
);
)
const sendSetNodeScroll = app.safe((s: [number, number], node: Node): void => {
const id = app.nodes.getID(node);
const id = app.nodes.getID(node)
if (id !== undefined) {
app.send(SetNodeScroll(id, s[0], s[1]));
app.send(SetNodeScroll(id, s[0], s[1]))
}
});
})
app.attachStartCallback(sendSetViewportScroll);
app.attachStartCallback(sendSetViewportScroll)
app.attachStopCallback(() => {
documentScroll = false;
nodeScroll.clear();
});
documentScroll = false
nodeScroll.clear()
})
app.nodes.attachNodeCallback((node, isStart) => {
if (isStart && isElementNode(node) && node.scrollLeft + node.scrollTop > 0) {
nodeScroll.set(node, [node.scrollLeft, node.scrollTop]);
nodeScroll.set(node, [node.scrollLeft, node.scrollTop])
}
});
})
app.attachEventListener(window, 'scroll', (e: Event): void => {
const target = e.target;
const target = e.target
if (target === document) {
documentScroll = true;
return;
documentScroll = true
return
}
if (target instanceof Element) {
nodeScroll.set(target, [target.scrollLeft, target.scrollTop]);
nodeScroll.set(target, [target.scrollLeft, target.scrollTop])
}
});
})
app.ticker.attach(
(): void => {
if (documentScroll) {
sendSetViewportScroll();
documentScroll = false;
sendSetViewportScroll()
documentScroll = false
}
nodeScroll.forEach(sendSetNodeScroll);
nodeScroll.clear();
nodeScroll.forEach(sendSetNodeScroll)
nodeScroll.clear()
},
5,
false,
);
)
}

View file

@ -1,62 +1,62 @@
import type App from '../app/index.js';
import { hasTag } from '../app/guards.js';
import { isURL } from '../utils.js';
import { ResourceTiming, PageLoadTiming, PageRenderTiming } from '../app/messages.gen.js';
import type App from '../app/index.js'
import { hasTag } from '../app/guards.js'
import { isURL } from '../utils.js'
import { ResourceTiming, PageLoadTiming, PageRenderTiming } from '../app/messages.gen.js'
// Inspired by https://github.com/WPO-Foundation/RUM-SpeedIndex/blob/master/src/rum-speedindex.js
interface ResourcesTimeMap {
[k: string]: number;
[k: string]: number
}
interface PaintBlock {
time: number;
area: number;
time: number
area: number
}
function getPaintBlocks(resources: ResourcesTimeMap): Array<PaintBlock> {
const paintBlocks: Array<PaintBlock> = [];
const elements = document.getElementsByTagName('*');
const styleURL = /url\(("[^"]*"|'[^']*'|[^)]*)\)/i;
const paintBlocks: Array<PaintBlock> = []
const elements = document.getElementsByTagName('*')
const styleURL = /url\(("[^"]*"|'[^']*'|[^)]*)\)/i
for (let i = 0; i < elements.length; i++) {
const element = elements[i];
let src = '';
const element = elements[i]
let src = ''
if (hasTag(element, 'IMG')) {
src = element.currentSrc || element.src;
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);
const matches = styleURL.exec(backgroundImage)
if (matches !== null) {
src = matches[1];
src = matches[1]
if (src.startsWith('"') || src.startsWith("'")) {
src = src.substr(1, src.length - 2);
src = src.substr(1, src.length - 2)
}
}
}
}
if (!src) continue;
const time = src.substr(0, 10) === 'data:image' ? 0 : resources[src];
if (time === undefined) continue;
const rect = element.getBoundingClientRect();
const top = Math.max(rect.top, 0);
const left = Math.max(rect.left, 0);
if (!src) continue
const time = src.substr(0, 10) === 'data:image' ? 0 : resources[src]
if (time === undefined) continue
const rect = element.getBoundingClientRect()
const top = Math.max(rect.top, 0)
const left = Math.max(rect.left, 0)
const bottom = Math.min(
rect.bottom,
window.innerHeight ||
(document.documentElement && document.documentElement.clientHeight) ||
0,
);
)
const right = Math.min(
rect.right,
window.innerWidth || (document.documentElement && document.documentElement.clientWidth) || 0,
);
if (bottom <= top || right <= left) continue;
const area = (bottom - top) * (right - left);
paintBlocks.push({ time, area });
)
if (bottom <= top || right <= left) continue
const area = (bottom - top) * (right - left)
paintBlocks.push({ time, area })
}
return paintBlocks;
return paintBlocks
}
function calculateSpeedIndex(firstContentfulPaint: number, paintBlocks: Array<PaintBlock>): number {
@ -69,20 +69,20 @@ function calculateSpeedIndex(firstContentfulPaint: number, paintBlocks: Array<Pa
(document.documentElement && document.documentElement.clientHeight) || 0,
window.innerHeight || 0,
)) /
10;
let s = a * firstContentfulPaint;
10
let s = a * firstContentfulPaint
for (let i = 0; i < paintBlocks.length; i++) {
const { time, area } = paintBlocks[i];
a += area;
s += area * (time > firstContentfulPaint ? time : firstContentfulPaint);
const { time, area } = paintBlocks[i]
a += area
s += area * (time > firstContentfulPaint ? time : firstContentfulPaint)
}
return a === 0 ? 0 : s / a;
return a === 0 ? 0 : s / a
}
export interface Options {
captureResourceTimings: boolean;
capturePageLoadTimings: boolean;
capturePageRenderTimings: boolean;
captureResourceTimings: boolean
capturePageLoadTimings: boolean
capturePageRenderTimings: boolean
}
export default function (app: App, opts: Partial<Options>): void {
@ -93,20 +93,20 @@ export default function (app: App, opts: Partial<Options>): void {
capturePageRenderTimings: true,
},
opts,
);
)
if (!('PerformanceObserver' in window)) {
options.captureResourceTimings = false;
options.captureResourceTimings = false
}
if (!options.captureResourceTimings) {
return;
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 (entry.duration < 0 || !isURL(entry.name) || app.isServiceURL(entry.name)) return
if (resources !== null) {
resources[entry.name] = entry.startTime + entry.duration;
resources[entry.name] = entry.startTime + entry.duration
}
app.send(
ResourceTiming(
@ -119,51 +119,51 @@ export default function (app: App, opts: Partial<Options>): void {
entry.name,
entry.initiatorType,
),
);
)
}
const observer: PerformanceObserver = new PerformanceObserver((list) =>
list.getEntries().forEach(resourceTiming),
);
)
let prevSessionID: string | undefined;
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;
performance.getEntriesByType('resource').forEach(resourceTiming)
prevSessionID = sessionID
}
observer.observe({ entryTypes: ['resource'] });
});
observer.observe({ entryTypes: ['resource'] })
})
app.attachStopCallback(function () {
observer.disconnect();
});
observer.disconnect()
})
let firstPaint = 0,
firstContentfulPaint = 0;
firstContentfulPaint = 0
if (options.capturePageLoadTimings) {
let pageLoadTimingSent = false;
let pageLoadTimingSent = false
app.ticker.attach(() => {
if (pageLoadTimingSent) {
return;
return
}
if (firstPaint === 0 || firstContentfulPaint === 0) {
performance.getEntriesByType('paint').forEach((entry: PerformanceEntry) => {
const { name, startTime } = entry;
const { name, startTime } = entry
switch (name) {
case 'first-paint':
firstPaint = startTime;
break;
firstPaint = startTime
break
case 'first-contentful-paint':
firstContentfulPaint = startTime;
break;
firstContentfulPaint = startTime
break
}
});
})
}
if (performance.timing.loadEventEnd || performance.now() > 30000) {
pageLoadTimingSent = true;
pageLoadTimingSent = true
const {
navigationStart,
requestStart,
@ -173,7 +173,7 @@ export default function (app: App, opts: Partial<Options>): void {
domContentLoadedEventEnd,
loadEventStart,
loadEventEnd,
} = performance.timing;
} = performance.timing
app.send(
PageLoadTiming(
requestStart - navigationStart || 0,
@ -186,46 +186,46 @@ export default function (app: App, opts: Partial<Options>): void {
firstPaint,
firstContentfulPaint,
),
);
)
}
}, 30);
}, 30)
}
if (options.capturePageRenderTimings) {
let visuallyComplete = 0,
interactiveWindowStartTime = 0,
interactiveWindowTickTime: number | null = 0,
paintBlocks: Array<PaintBlock> | null = null;
paintBlocks: Array<PaintBlock> | null = null
let pageRenderTimingSent = false;
let pageRenderTimingSent = false
app.ticker.attach(() => {
if (pageRenderTimingSent) {
return;
return
}
const time = performance.now();
const time = performance.now()
if (resources !== null) {
visuallyComplete = Math.max.apply(
null,
Object.keys(resources).map((k) => (resources as any)[k]),
);
)
if (time - visuallyComplete > 1000) {
paintBlocks = getPaintBlocks(resources);
resources = null;
paintBlocks = getPaintBlocks(resources)
resources = null
}
}
if (interactiveWindowTickTime !== null) {
if (time - interactiveWindowTickTime > 50) {
interactiveWindowStartTime = time;
interactiveWindowStartTime = time
}
interactiveWindowTickTime = time - interactiveWindowStartTime > 5000 ? null : time;
interactiveWindowTickTime = time - interactiveWindowStartTime > 5000 ? null : time
}
if ((paintBlocks !== null && interactiveWindowTickTime === null) || time > 30000) {
pageRenderTimingSent = true;
resources = null;
pageRenderTimingSent = true
resources = null
const speedIndex =
paintBlocks === null
? 0
: calculateSpeedIndex(firstContentfulPaint || firstPaint, paintBlocks);
: calculateSpeedIndex(firstContentfulPaint || firstPaint, paintBlocks)
const timeToInteractive =
interactiveWindowTickTime === null
? Math.max(
@ -234,15 +234,15 @@ export default function (app: App, opts: Partial<Options>): void {
performance.timing.domContentLoadedEventEnd - performance.timing.navigationStart ||
0,
)
: 0;
: 0
app.send(
PageRenderTiming(
speedIndex,
firstContentfulPaint > visuallyComplete ? firstContentfulPaint : visuallyComplete,
timeToInteractive,
),
);
)
}
});
})
}
}

View file

@ -1,40 +1,40 @@
import type App from '../app/index.js';
import { SetPageLocation, SetViewportSize, SetPageVisibility } from '../app/messages.gen.js';
import type App from '../app/index.js'
import { SetPageLocation, SetViewportSize, SetPageVisibility } from '../app/messages.gen.js'
export default function (app: App): void {
let url: string, width: number, height: number;
let navigationStart = performance.timing.navigationStart;
let url: string, width: number, height: number
let navigationStart = performance.timing.navigationStart
const sendSetPageLocation = app.safe(() => {
const { URL } = document;
const { URL } = document
if (URL !== url) {
url = URL;
app.send(SetPageLocation(url, document.referrer, navigationStart));
navigationStart = 0;
url = URL
app.send(SetPageLocation(url, document.referrer, navigationStart))
navigationStart = 0
}
});
})
const sendSetViewportSize = app.safe(() => {
const { innerWidth, innerHeight } = window;
const { innerWidth, innerHeight } = window
if (innerWidth !== width || innerHeight !== height) {
width = innerWidth;
height = innerHeight;
app.send(SetViewportSize(width, height));
width = innerWidth
height = innerHeight
app.send(SetViewportSize(width, height))
}
});
})
const sendSetPageVisibility =
document.hidden === undefined
? Function.prototype
: app.safe(() => app.send(SetPageVisibility(document.hidden)));
: app.safe(() => app.send(SetPageVisibility(document.hidden)))
app.attachStartCallback(() => {
url = '';
width = height = -1;
sendSetPageLocation();
sendSetViewportSize();
sendSetPageVisibility();
});
url = ''
width = height = -1
sendSetPageLocation()
sendSetViewportSize()
sendSetPageVisibility()
})
if (document.hidden !== undefined) {
app.attachEventListener(
@ -43,9 +43,9 @@ export default function (app: App): void {
sendSetPageVisibility as EventListener,
false,
false,
);
)
}
app.ticker.attach(sendSetPageLocation, 1, false);
app.ticker.attach(sendSetViewportSize, 5, false);
app.ticker.attach(sendSetPageLocation, 1, false)
app.ticker.attach(sendSetViewportSize, 5, false)
}

View file

@ -1,65 +1,65 @@
export function timestamp(): number {
return Math.round(performance.now()) + performance.timing.navigationStart;
return Math.round(performance.now()) + performance.timing.navigationStart
}
export const stars: (str: string) => string =
'repeat' in String.prototype
? (str: string): string => '*'.repeat(str.length)
: (str: string): string => str.replace(/./g, '*');
: (str: string): string => str.replace(/./g, '*')
export function normSpaces(str: string): string {
return str.trim().replace(/\s+/g, ' ');
return str.trim().replace(/\s+/g, ' ')
}
// 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';
export const DOCS_HOST = 'https://docs.openreplay.com'
const warnedFeatures: { [key: string]: boolean } = {};
const warnedFeatures: { [key: string]: boolean } = {}
export function deprecationWarn(nameOfFeature: string, useInstead: string, docsPath = '/'): void {
if (warnedFeatures[nameOfFeature]) {
return;
return
}
console.warn(
`OpenReplay: ${nameOfFeature} is deprecated. ${
useInstead ? `Please, use ${useInstead} instead.` : ''
} Visit ${DOCS_HOST}${docsPath} for more information.`,
);
warnedFeatures[nameOfFeature] = true;
)
warnedFeatures[nameOfFeature] = true
}
export function getLabelAttribute(e: Element): string | null {
let value = e.getAttribute('data-openreplay-label');
let value = e.getAttribute('data-openreplay-label')
if (value !== null) {
return value;
return value
}
value = e.getAttribute('data-asayer-label');
value = e.getAttribute('data-asayer-label')
if (value !== null) {
deprecationWarn('"data-asayer-label" attribute', '"data-openreplay-label" attribute', '/');
deprecationWarn('"data-asayer-label" attribute', '"data-openreplay-label" attribute', '/')
}
return value;
return value
}
export function hasOpenreplayAttribute(e: Element, name: string): boolean {
const newName = `data-openreplay-${name}`;
const newName = `data-openreplay-${name}`
if (e.hasAttribute(newName)) {
return true;
return true
}
const oldName = `data-asayer-${name}`;
const oldName = `data-asayer-${name}`
if (e.hasAttribute(oldName)) {
deprecationWarn(
`"${oldName}" attribute`,
`"${newName}" attribute`,
'/installation/sanitize-data',
);
return true;
)
return true
}
return false;
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';
return 'html'
}
const defaults: Options = {
@ -46,264 +46,264 @@ 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)),
);
)
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;
return rootNode.ownerDocument
}
return rootNode;
return rootNode
}
function bottomUpSearch(input: Element, limit: Limit, fallback?: () => Path | null): Path | null {
let path: Path | null = null;
const 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()];
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 (const node of level) {
node.level = i;
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 (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 }),
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,
}),
);
)
}
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,
}),
);
)
}
function tagName(input: Element): Node | null {
const name = input.tagName.toLowerCase();
const name = input.tagName.toLowerCase()
if (config.tagName(name)) {
return {
name,
penalty: 2,
};
}
}
return null;
return null
}
function any(): Node {
return {
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,
};
}
}
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 (const node of stack[0]) {
paths.push(...combinations(stack.slice(1, stack.length), path.concat(node)));
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,
@ -313,103 +313,103 @@ function optimize(
visited: new Map<string, boolean>(),
},
): Node[][] {
const paths: 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,
};
}
function cssesc(string: string, opt: Partial<typeof defaultOptions> = {}) {
const options = { ...defaultOptions, ...opt };
const options = { ...defaultOptions, ...opt }
if (options.quotes != 'single' && options.quotes != 'double') {
options.quotes = 'single';
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) {
// Its a high surrogate, and there is a next character.
const extra = string.charCodeAt(counter++);
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() + ' ';
value = '\\' + codePoint.toString(16).toUpperCase() + ' '
} else if (
character == '\\' ||
(!isIdentifier &&
((character == '"' && quote == character) || (character == "'" && quote == character))) ||
(isIdentifier && regexSingleEscape.test(character))
) {
value = '\\' + 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)
}
}
@ -419,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,17 +1,17 @@
import type Message from '../common/messages.gen.js';
import * as Messages from '../common/messages.gen.js';
import MessageEncoder from './MessageEncoder.gen.js';
import PrimitiveEncoder from './PrimitiveEncoder.js';
import type Message from '../common/messages.gen.js'
import * as Messages from '../common/messages.gen.js'
import MessageEncoder from './MessageEncoder.gen.js'
import PrimitiveEncoder from './PrimitiveEncoder.js'
const SIZE_BYTES = 2;
const MAX_M_SIZE = (1 << (SIZE_BYTES * 8)) - 1;
const SIZE_BYTES = 2
const MAX_M_SIZE = (1 << (SIZE_BYTES * 8)) - 1
export default class BatchWriter {
private nextIndex = 0;
private beaconSize = 2 * 1e5; // Default 200kB
private encoder = new MessageEncoder(this.beaconSize);
private readonly sizeEncoder = new PrimitiveEncoder(SIZE_BYTES);
private isEmpty = true;
private nextIndex = 0
private beaconSize = 2 * 1e5 // Default 200kB
private encoder = new MessageEncoder(this.beaconSize)
private readonly sizeEncoder = new PrimitiveEncoder(SIZE_BYTES)
private isEmpty = true
constructor(
private readonly pageNo: number,
@ -19,12 +19,12 @@ export default class BatchWriter {
private url: string,
private readonly onBatch: (batch: Uint8Array) => void,
) {
this.prepare();
this.prepare()
}
private prepare(): void {
if (!this.encoder.isEmpty()) {
return;
return
}
// MBTODO: move service-messages creation to webworker
const batchMetadata: Messages.BatchMetadata = [
@ -34,76 +34,76 @@ export default class BatchWriter {
this.nextIndex,
this.timestamp,
this.url,
];
this.encoder.encode(batchMetadata);
this.isEmpty = true;
]
this.encoder.encode(batchMetadata)
this.isEmpty = true
}
private write(message: Message): boolean {
const e = this.encoder;
const e = this.encoder
if (!e.uint(message[0]) || !e.skip(SIZE_BYTES)) {
// TODO: app.debug.log
return false;
return false
}
const startOffset = e.getCurrentOffset();
const wasWritten = e.encode(message);
const startOffset = e.getCurrentOffset()
const wasWritten = e.encode(message)
if (wasWritten) {
const endOffset = e.getCurrentOffset();
const size = endOffset - startOffset;
const endOffset = e.getCurrentOffset()
const size = endOffset - startOffset
if (size > MAX_M_SIZE || !this.sizeEncoder.uint(size)) {
console.warn('OpenReplay: max message size overflow.');
return false;
console.warn('OpenReplay: max message size overflow.')
return false
}
this.sizeEncoder.checkpoint(); // TODO: separate checkpoint logic to an Encoder-inherit class
e.set(this.sizeEncoder.flush(), startOffset - SIZE_BYTES);
this.sizeEncoder.checkpoint() // TODO: separate checkpoint logic to an Encoder-inherit class
e.set(this.sizeEncoder.flush(), startOffset - SIZE_BYTES)
e.checkpoint();
this.isEmpty = false;
this.nextIndex++;
e.checkpoint()
this.isEmpty = false
this.nextIndex++
}
// app.debug.log
return wasWritten;
return wasWritten
}
private beaconSizeLimit = 1e6;
private beaconSizeLimit = 1e6
setBeaconSizeLimit(limit: number) {
this.beaconSizeLimit = limit;
this.beaconSizeLimit = limit
}
writeMessage(message: Message) {
if (message[0] === Messages.Type.Timestamp) {
this.timestamp = message[1]; // .timestamp
this.timestamp = message[1] // .timestamp
}
if (message[0] === Messages.Type.SetPageLocation) {
this.url = message[1]; // .url
this.url = message[1] // .url
}
if (this.write(message)) {
return;
return
}
this.finaliseBatch();
this.finaliseBatch()
while (!this.write(message)) {
if (this.beaconSize === this.beaconSizeLimit) {
console.warn('OpenReplay: beacon size overflow. Skipping large message.', message);
this.encoder.reset();
this.prepare();
return;
console.warn('OpenReplay: beacon size overflow. Skipping large message.', message)
this.encoder.reset()
this.prepare()
return
}
// MBTODO: tempWriter for one message?
this.beaconSize = Math.min(this.beaconSize * 2, this.beaconSizeLimit);
this.encoder = new MessageEncoder(this.beaconSize);
this.prepare();
this.beaconSize = Math.min(this.beaconSize * 2, this.beaconSizeLimit)
this.encoder = new MessageEncoder(this.beaconSize)
this.prepare()
}
}
finaliseBatch() {
if (this.isEmpty) {
return;
return
}
this.onBatch(this.encoder.flush());
this.prepare();
this.onBatch(this.encoder.flush())
this.prepare()
}
clean() {
this.encoder.reset();
this.encoder.reset()
}
}

View file

@ -1,4 +1,4 @@
declare const TextEncoder: any;
declare const TextEncoder: any
const textEncoder: { encode(str: string): Uint8Array } =
typeof TextEncoder === 'function'
? new TextEncoder()
@ -6,112 +6,112 @@ const textEncoder: { encode(str: string): Uint8Array } =
// Based on https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder
encode(str): Uint8Array {
const Len = str.length,
resArr = new Uint8Array(Len * 3);
let resPos = -1;
resArr = new Uint8Array(Len * 3)
let resPos = -1
for (let point = 0, nextcode = 0, i = 0; i !== Len; ) {
(point = str.charCodeAt(i)), (i += 1);
;(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*/
break;
resArr[(resPos += 1)] = 0xef /*0b11101111*/
resArr[(resPos += 1)] = 0xbf /*0b10111111*/
resArr[(resPos += 1)] = 0xbd /*0b10111101*/
break
}
// https://mathiasbynens.be/notes/javascript-encoding#surrogate-formulae
nextcode = str.charCodeAt(i);
nextcode = str.charCodeAt(i)
if (nextcode >= 0xdc00 && nextcode <= 0xdfff) {
point = (point - 0xd800) * 0x400 + nextcode - 0xdc00 + 0x10000;
i += 1;
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)
resArr[(resPos += 1)] =
(0x2 /*0b10*/ << 6) | ((point >>> 12) & 0x3f); /*0b00111111*/
(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*/
continue;
(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*/
continue;
resArr[(resPos += 1)] = 0xef /*0b11101111*/
resArr[(resPos += 1)] = 0xbf /*0b10111111*/
resArr[(resPos += 1)] = 0xbd /*0b10111101*/
continue
}
}
if (point <= 0x007f) {
resArr[(resPos += 1)] = (0x0 /*0b0*/ << 7) | point;
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)] = (0x6 /*0b110*/ << 5) | (point >>> 6)
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)] = (0xe /*0b1110*/ << 4) | (point >>> 12)
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);
return resArr.subarray(0, resPos + 1)
},
};
}
export default class PrimitiveEncoder {
private offset = 0;
private checkpointOffset = 0;
private readonly data: Uint8Array;
private offset = 0
private checkpointOffset = 0
private readonly data: Uint8Array
constructor(private readonly size: number) {
this.data = new Uint8Array(size);
this.data = new Uint8Array(size)
}
getCurrentOffset(): number {
return this.offset;
return this.offset
}
checkpoint() {
this.checkpointOffset = this.offset;
this.checkpointOffset = this.offset
}
isEmpty(): boolean {
return this.offset === 0;
return this.offset === 0
}
skip(n: number): boolean {
this.offset += n;
return this.offset <= this.size;
this.offset += n
return this.offset <= this.size
}
set(bytes: Uint8Array, offset: number) {
this.data.set(bytes, offset);
this.data.set(bytes, offset)
}
boolean(value: boolean): boolean {
this.data[this.offset++] = +value;
return this.offset <= this.size;
this.data[this.offset++] = +value
return this.offset <= this.size
}
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;
value = Math.floor(value / 128);
this.data[this.offset++] = value % 0x100 | 0x80
value = Math.floor(value / 128)
}
this.data[this.offset++] = value;
return this.offset <= this.size;
this.data[this.offset++] = value
return this.offset <= this.size
}
int(value: number): boolean {
value = Math.round(value);
return this.uint(value >= 0 ? value * 2 : value * -2 - 1);
value = Math.round(value)
return this.uint(value >= 0 ? value * 2 : value * -2 - 1)
}
string(value: string): boolean {
const encoded = textEncoder.encode(value);
const length = encoded.byteLength;
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;
return true;
this.data.set(encoded, this.offset)
this.offset += length
return true
}
reset(): void {
this.offset = 0;
this.checkpointOffset = 0;
this.offset = 0
this.checkpointOffset = 0
}
flush(): Uint8Array {
const data = this.data.slice(0, this.checkpointOffset);
this.reset();
return data;
const data = this.data.slice(0, this.checkpointOffset)
this.reset()
return data
}
}

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()
@ -21,11 +21,11 @@ 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: () => any,
@ -33,33 +33,33 @@ export default class QueueSender {
private readonly MAX_ATTEMPTS_COUNT = 10,
private readonly ATTEMPT_TIMEOUT = 1000,
) {
this.ingestURL = ingestBaseURL + INGEST_PATH;
this.ingestURL = ingestBaseURL + INGEST_PATH
}
authorise(token: string): void {
this.token = token;
this.token = token
}
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): 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;
this.busy = true
fetch(this.ingestURL, {
body: batch,
@ -73,30 +73,30 @@ export default class QueueSender {
.then((r) => {
if (r.status === 401) {
// TODO: continuous session ?
this.busy = false;
this.onUnauthorised();
return;
this.busy = false
this.onUnauthorised()
return
} else if (r.status >= 400) {
this.retry(batch);
return;
this.retry(batch)
return
}
// Success
this.attemptsCount = 0;
const nextBatch = this.queue.shift();
this.attemptsCount = 0
const nextBatch = this.queue.shift()
if (nextBatch) {
this.sendBatch(nextBatch);
this.sendBatch(nextBatch)
} else {
this.busy = false;
this.busy = false
}
})
.catch((e) => {
console.warn('OpenReplay:', e);
this.retry(batch);
});
console.warn('OpenReplay:', e)
this.retry(batch)
})
}
clean() {
this.queue.length = 0;
this.queue.length = 0
}
}

View file

@ -1,9 +1,9 @@
import type Message from '../common/messages.gen.js';
import { Type as MType } from '../common/messages.gen.js';
import { WorkerMessageData } from '../common/interaction.js';
import type Message from '../common/messages.gen.js'
import { Type as MType } from '../common/messages.gen.js'
import { WorkerMessageData } from '../common/interaction.js'
import QueueSender from './QueueSender.js';
import BatchWriter from './BatchWriter.js';
import QueueSender from './QueueSender.js'
import BatchWriter from './BatchWriter.js'
enum WorkerStatus {
NotActive,
@ -12,112 +12,112 @@ enum WorkerStatus {
Active,
}
const AUTO_SEND_INTERVAL = 10 * 1000;
const AUTO_SEND_INTERVAL = 10 * 1000
let sender: QueueSender | null = null;
let writer: BatchWriter | null = null;
let workerStatus: WorkerStatus = WorkerStatus.NotActive;
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(): void {
workerStatus = WorkerStatus.Stopping;
workerStatus = WorkerStatus.Stopping
if (sendIntervalID !== null) {
clearInterval(sendIntervalID);
sendIntervalID = null;
clearInterval(sendIntervalID)
sendIntervalID = null
}
if (writer) {
writer.clean();
writer = null;
writer.clean()
writer = null
}
workerStatus = WorkerStatus.NotActive;
workerStatus = WorkerStatus.NotActive
}
function resetCleanQueue(): void {
if (sender) {
sender.clean();
sender = null;
sender.clean()
sender = null
}
reset();
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>): any => {
if (data == null) {
send(); // TODO: sendAll?
return;
send() // TODO: sendAll?
return
}
if (data === 'stop') {
send();
reset();
return;
send()
reset()
return
}
if (Array.isArray(data)) {
// Message[]
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
data.forEach((message) => {
if (message[0] === MType.SetPageVisibility) {
if (message[1]) {
// .hidden
restartTimeoutID = setTimeout(() => self.postMessage('restart'), 30 * 60 * 1000);
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');
self.postMessage('restart')
},
() => {
// onFailure
resetCleanQueue();
self.postMessage('failed');
resetCleanQueue()
self.postMessage('failed')
},
data.connAttemptCount,
data.connAttemptGap,
);
)
writer = new BatchWriter(
data.pageNo,
data.timestamp,
data.url,
// onBatch
(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 (!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
}
};
}