frat(tracker): 3.4.16: observer for shRoot, topObserver & sanitizer refactor; 3.4.17:autoResetOnWindowOpen; userID onStart option; restart fix

This commit is contained in:
ShiKhu 2022-01-23 18:28:22 +01:00
parent 7196b81979
commit b3fba224cc
16 changed files with 853 additions and 622 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@openreplay/tracker",
"version": "3.4.7",
"version": "3.4.12",
"lockfileVersion": 1,
"requires": true,
"dependencies": {

View file

@ -1,7 +1,7 @@
{
"name": "@openreplay/tracker",
"description": "The OpenReplay tracker main package",
"version": "3.4.12",
"version": "3.4.17",
"keywords": [
"logging",
"replay"

View file

@ -0,0 +1,72 @@
// TODO: global type
export interface Window extends globalThis.Window {
HTMLInputElement: typeof HTMLInputElement,
HTMLLinkElement: typeof HTMLLinkElement,
HTMLStyleElement: typeof HTMLStyleElement,
SVGStyleElement: typeof SVGStyleElement,
HTMLIFrameElement: typeof HTMLIFrameElement,
Text: typeof Text,
Element: typeof Element,
ShadowRoot: typeof ShadowRoot,
//parent: Window,
}
type WindowConstructor =
Document |
Element |
Text |
ShadowRoot |
HTMLInputElement |
HTMLLinkElement |
HTMLStyleElement |
HTMLIFrameElement
// type ConstructorNames =
// 'Element' |
// 'Text' |
// 'HTMLInputElement' |
// 'HTMLLinkElement' |
// 'HTMLStyleElement' |
// 'HTMLIFrameElement'
type Constructor<T> = { new (...args: any[]): T , name: string };
// TODO: we need a type expert here so we won't have to ignore the lines
// TODO: use it everywhere (static function; export from which file? <-- global Window typing required)
export function isInstance<T extends WindowConstructor>(node: Node, constr: Constructor<T>): node is T {
const doc = node.ownerDocument;
if (!doc) { // null if Document
return constr.name === 'Document';
}
let context: Window =
// @ts-ignore (for EI, Safary)
doc.parentWindow ||
doc.defaultView; // TODO: smart global typing for Window object
while(context.parent && context.parent !== context) {
// @ts-ignore
if (node instanceof context[constr.name]) {
return true
}
// @ts-ignore
context = context.parent
}
// @ts-ignore
return node instanceof context[constr.name]
}
export function inDocument(node: Node): boolean {
const doc = node.ownerDocument
if (!doc) { return false }
if (doc.contains(node)) { return true }
let context: Window =
// @ts-ignore (for EI, Safary)
doc.parentWindow ||
doc.defaultView;
while(context.parent && context.parent !== context) {
if (context.document.contains(node)) {
return true
}
// @ts-ignore
context = context.parent
}
return false;
}

View file

@ -2,12 +2,15 @@ import { timestamp, log, warn } from "../utils.js";
import { Timestamp, PageClose } from "../../messages/index.js";
import Message from "../../messages/message.js";
import Nodes from "./nodes.js";
import Observer from "./observer.js";
import Observer from "./observer/top_observer.js";
import Sanitizer from "./sanitizer.js";
import Ticker from "./ticker.js";
import { deviceMemory, jsHeapSizeLimit } from "../modules/performance.js";
import type { Options as ObserverOptions } from "./observer.js";
import type { Options as ObserverOptions } from "./observer/top_observer.js";
import type { Options as SanitizerOptions } from "./sanitizer.js";
import type { Options as WebworkerOptions, WorkerMessageData } from "../../messages/webworker.js";
@ -17,11 +20,17 @@ export interface OnStartInfo {
userUUID: string,
}
export type Options = {
export interface StartOptions {
userID?: string,
forceNew: boolean,
}
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?
@ -30,7 +39,9 @@ export type Options = {
__debug_report_edp: string | null;
__debug_log: boolean;
onStart?: (info: OnStartInfo) => void;
} & ObserverOptions & WebworkerOptions;
} & WebworkerOptions;
export type Options = AppOptions & ObserverOptions & SanitizerOptions
type Callback = () => void;
type CommitCallback = (messages: Array<Message>) => void;
@ -43,21 +54,23 @@ export default class App {
readonly nodes: Nodes;
readonly ticker: Ticker;
readonly projectKey: string;
readonly sanitizer: Sanitizer;
private readonly messages: Array<Message> = [];
/*private*/ readonly observer: Observer; // temp, for fast security fix. TODO: separate security/obscure module with nodeCallback that incapsulates `textMasked` functionality from Observer
private readonly observer: Observer;
private readonly startCallbacks: Array<Callback> = [];
private readonly stopCallbacks: Array<Callback> = [];
private readonly commitCallbacks: Array<CommitCallback> = [];
private readonly options: Options;
private readonly options: AppOptions;
private readonly revID: string;
private _sessionID: string | null = null;
private _userID: string | undefined;
private isActive = false;
private version = 'TRACKER_VERSION';
private readonly worker?: Worker;
constructor(
projectKey: string,
sessionToken: string | null | undefined,
opts: Partial<Options>,
options: Partial<Options>,
) {
this.projectKey = projectKey;
this.options = Object.assign(
@ -66,24 +79,23 @@ export default class App {
node_id: '__openreplay_id',
session_token_key: '__openreplay_token',
session_pageno_key: '__openreplay_pageno',
session_reset_key: '__openreplay_reset',
local_uuid_key: '__openreplay_uuid',
ingestPoint: DEFAULT_INGEST_POINT,
resourceBaseHref: null,
__is_snippet: false,
__debug_report_edp: null,
__debug_log: false,
obscureTextEmails: true,
obscureTextNumbers: false,
captureIFrames: false,
},
opts,
options,
);
if (sessionToken != null) {
sessionStorage.setItem(this.options.session_token_key, sessionToken);
}
this.revID = this.options.revID;
this.sanitizer = new Sanitizer(this, options);
this.nodes = new Nodes(this.options.node_id);
this.observer = new Observer(this, this.options);
this.observer = new Observer(this, options);
this.ticker = new Ticker(this);
this.ticker.attach(() => this.commit());
try {
@ -102,7 +114,10 @@ export default class App {
this.stop();
} else if (data === "restart") {
this.stop();
this.start(true);
this.start({
forceNew: true,
userID: this._userID,
});
}
};
const alertWorker = () => {
@ -244,104 +259,132 @@ export default class App {
active(): boolean {
return this.isActive;
}
private _start(reset: boolean): Promise<OnStartInfo> {
if (!this.isActive) {
if (!this.worker) {
return Promise.reject("No worker found: perhaps, CSP is not set.");
}
this.isActive = true;
let pageNo: number = 0;
const pageNoStr = sessionStorage.getItem(this.options.session_pageno_key);
if (pageNoStr != null) {
pageNo = parseInt(pageNoStr);
pageNo++;
}
sessionStorage.setItem(this.options.session_pageno_key, pageNo.toString());
const startTimestamp = timestamp();
const messageData: WorkerMessageData = {
ingestPoint: this.options.ingestPoint,
pageNo,
startTimestamp,
connAttemptCount: this.options.connAttemptCount,
connAttemptGap: this.options.connAttemptGap,
}
this.worker.postMessage(messageData); // brings delay of 10th ms?
return window.fetch(this.options.ingestPoint + '/v1/web/start', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token: sessionStorage.getItem(this.options.session_token_key),
userUUID: localStorage.getItem(this.options.local_uuid_key),
projectKey: this.projectKey,
revID: this.revID,
timestamp: startTimestamp,
trackerVersion: this.version,
isSnippet: this.options.__is_snippet,
deviceMemory,
jsHeapSizeLimit,
reset,
}),
})
.then(r => {
if (r.status === 200) {
return r.json()
} else { // TODO: handle canceling && 403
return r.text().then(text => {
throw new Error(`Server error: ${r.status}. ${text}`);
});
}
})
.then(r => {
const { token, userUUID, sessionID, beaconSizeLimit } = r;
if (typeof token !== 'string' ||
typeof userUUID !== 'string' ||
(typeof beaconSizeLimit !== 'number' && typeof beaconSizeLimit !== 'undefined')) {
throw new Error(`Incorrect server response: ${ JSON.stringify(r) }`);
}
sessionStorage.setItem(this.options.session_token_key, token);
localStorage.setItem(this.options.local_uuid_key, userUUID);
if (typeof sessionID === 'string') {
this._sessionID = sessionID;
}
if (!this.worker) {
throw new Error("no worker found after start request (this might not happen)");
}
this.worker.postMessage({ token, beaconSizeLimit });
this.startCallbacks.forEach((cb) => cb());
this.observer.observe();
this.ticker.start();
log("OpenReplay tracking started.");
const onStartInfo = { sessionToken: token, userUUID, sessionID };
if (typeof this.options.onStart === 'function') {
this.options.onStart(onStartInfo);
}
return onStartInfo;
})
.catch(e => {
sessionStorage.removeItem(this.options.session_token_key)
this.stop()
warn("OpenReplay was unable to start. ", e)
this._debug("session_start", e);
throw e
})
resetNextPageSession(flag: boolean) {
if (flag) {
sessionStorage.setItem(this.options.session_reset_key, 't');
} else {
sessionStorage.removeItem(this.options.session_reset_key);
}
return Promise.reject("Player is already active");
}
private _start(startOpts: StartOptions): Promise<OnStartInfo> {
if (!this.worker) {
return Promise.reject("No worker found: perhaps, CSP is not set.");
}
if (this.isActive) {
return Promise.reject("OpenReplay: trying to call `start()` on the instance that has been started already.")
}
this.isActive = true;
let pageNo: number = 0;
const pageNoStr = sessionStorage.getItem(this.options.session_pageno_key);
if (pageNoStr != null) {
pageNo = parseInt(pageNoStr);
pageNo++;
}
sessionStorage.setItem(this.options.session_pageno_key, pageNo.toString());
const startTimestamp = timestamp();
const messageData: WorkerMessageData = {
ingestPoint: this.options.ingestPoint,
pageNo,
startTimestamp,
connAttemptCount: this.options.connAttemptCount,
connAttemptGap: this.options.connAttemptGap,
}
this.worker.postMessage(messageData); // brings delay of 10th ms?
// let token = sessionStorage.getItem(this.options.session_token_key)
// const tokenIsActive = localStorage.getItem("__or_at_" + token)
// if (tokenIsActive) {
// token = null
// }
const sReset = sessionStorage.getItem(this.options.session_reset_key);
sessionStorage.removeItem(this.options.session_reset_key);
this._userID = startOpts.userID || undefined
return window.fetch(this.options.ingestPoint + '/v1/web/start', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token: sessionStorage.getItem(this.options.session_token_key),
userUUID: localStorage.getItem(this.options.local_uuid_key),
projectKey: this.projectKey,
revID: this.revID,
timestamp: startTimestamp,
trackerVersion: this.version,
isSnippet: this.options.__is_snippet,
deviceMemory,
jsHeapSizeLimit,
reset: startOpts.forceNew || sReset !== null,
userID: this._userID,
}),
})
.then(r => {
if (r.status === 200) {
return r.json()
} else { // TODO: handle canceling && 403
return r.text().then(text => {
throw new Error(`Server error: ${r.status}. ${text}`);
});
}
})
.then(r => {
const { token, userUUID, sessionID, beaconSizeLimit } = r;
if (typeof token !== 'string' ||
typeof userUUID !== 'string' ||
(typeof beaconSizeLimit !== 'number' && typeof beaconSizeLimit !== 'undefined')) {
throw new Error(`Incorrect server response: ${ JSON.stringify(r) }`);
}
sessionStorage.setItem(this.options.session_token_key, token);
localStorage.setItem(this.options.local_uuid_key, userUUID);
// localStorage.setItem("__or_at_" + token, "true")
// this.attachEventListener(window, 'beforeunload', ()=>{
// localStorage.removeItem("__or_at_" + token)
// }, false);
// this.attachEventListener(window, 'pagehide', ()=>{
// localStorage.removeItem("__or_at_" + token)
// }, false);
if (typeof sessionID === 'string') {
this._sessionID = sessionID;
}
if (!this.worker) {
throw new Error("no worker found after start request (this might not happen)");
}
this.worker.postMessage({ token, beaconSizeLimit });
this.startCallbacks.forEach((cb) => cb());
this.observer.observe();
this.ticker.start();
log("OpenReplay tracking started.");
const onStartInfo = { sessionToken: token, userUUID, sessionID };
if (typeof this.options.onStart === 'function') {
this.options.onStart(onStartInfo);
}
return onStartInfo;
})
.catch(e => {
sessionStorage.removeItem(this.options.session_token_key)
this.stop()
warn("OpenReplay was unable to start. ", e)
this._debug("session_start", e);
throw e
})
}
start(reset: boolean = false): Promise<OnStartInfo> {
start(options: StartOptions = { forceNew: false }): Promise<OnStartInfo> {
if (!document.hidden) {
return this._start(reset);
return this._start(options);
} else {
return new Promise((resolve) => {
const onVisibilityChange = () => {
if (!document.hidden) {
document.removeEventListener("visibilitychange", onVisibilityChange);
resolve(this._start(reset));
resolve(this._start(options));
}
}
document.addEventListener("visibilitychange", onVisibilityChange);
@ -354,6 +397,7 @@ export default class App {
if (this.worker) {
this.worker.postMessage("stop");
}
this.sanitizer.clear();
this.observer.disconnect();
this.nodes.clear();
this.ticker.stop();

View file

@ -1,484 +0,0 @@
import { stars, hasOpenreplayAttribute } from "../utils.js";
import {
CreateDocument,
CreateElementNode,
CreateTextNode,
SetNodeData,
SetCSSDataURLBased,
SetNodeAttribute,
SetNodeAttributeURLBased,
RemoveNodeAttribute,
MoveNode,
RemoveNode,
CreateIFrameDocument,
} from "../../messages/index.js";
import App from "./index.js";
interface Window extends WindowProxy {
HTMLInputElement: typeof HTMLInputElement,
HTMLLinkElement: typeof HTMLLinkElement,
HTMLStyleElement: typeof HTMLStyleElement,
SVGStyleElement: typeof SVGStyleElement,
HTMLIFrameElement: typeof HTMLIFrameElement,
Text: typeof Text,
Element: typeof Element,
//parent: Window,
}
type WindowConstructor =
Document |
Element |
Text |
HTMLInputElement |
HTMLLinkElement |
HTMLStyleElement |
HTMLIFrameElement
// type ConstructorNames =
// 'Element' |
// 'Text' |
// 'HTMLInputElement' |
// 'HTMLLinkElement' |
// 'HTMLStyleElement' |
// 'HTMLIFrameElement'
type Constructor<T> = { new (...args: any[]): T , name: string };
function isSVGElement(node: Element): node is SVGElement {
return node.namespaceURI === 'http://www.w3.org/2000/svg';
}
export interface Options {
obscureTextEmails: boolean;
obscureTextNumbers: boolean;
captureIFrames: boolean;
}
export default class Observer {
private readonly observer: MutationObserver;
private readonly commited: Array<boolean | undefined>;
private readonly recents: Array<boolean | undefined>;
private readonly indexes: Array<number>;
private readonly attributesList: Array<Set<string> | undefined>;
private readonly textSet: Set<number>;
private readonly textMasked: Set<number>;
constructor(private readonly app: App, private readonly options: Options, private readonly context: Window = window) {
this.observer = new MutationObserver(
this.app.safe((mutations) => {
for (const mutation of mutations) {
const target = mutation.target;
const type = mutation.type;
// Special case
// Document 'childList' might happen in case of iframe.
// TODO: generalize as much as possible
if (this.isInstance(target, Document)
&& type === 'childList'
//&& new Array(mutation.addedNodes).some(node => this.isInstance(node, HTMLHtmlElement))
) {
const parentFrame = target.defaultView?.frameElement
if (!parentFrame) { continue }
this.bindTree(target.documentElement)
const frameID = this.app.nodes.getID(parentFrame)
const docID = this.app.nodes.getID(target.documentElement)
if (frameID === undefined || docID === undefined) { continue }
this.app.send(CreateIFrameDocument(frameID, docID));
continue;
}
if (this.isIgnored(target) || !context.document.contains(target)) {
continue;
}
if (type === 'childList') {
for (let i = 0; i < mutation.removedNodes.length; i++) {
this.bindTree(mutation.removedNodes[i]);
}
for (let i = 0; i < mutation.addedNodes.length; i++) {
this.bindTree(mutation.addedNodes[i]);
}
continue;
}
const id = this.app.nodes.getID(target);
if (id === undefined) {
continue;
}
if (id >= this.recents.length) {
this.recents[id] = undefined;
}
if (type === 'attributes') {
const name = mutation.attributeName;
if (name === null) {
continue;
}
let attr = this.attributesList[id];
if (attr === undefined) {
this.attributesList[id] = attr = new Set();
}
attr.add(name);
continue;
}
if (type === 'characterData') {
this.textSet.add(id);
continue;
}
}
this.commitNodes();
}),
);
this.commited = [];
this.recents = [];
this.indexes = [0];
this.attributesList = [];
this.textSet = new Set();
this.textMasked = new Set();
}
private clear(): void {
this.commited.length = 0;
this.recents.length = 0;
this.indexes.length = 1;
this.attributesList.length = 0;
this.textSet.clear();
this.textMasked.clear();
}
// TODO: we need a type expert here so we won't have to ignore the lines
private isInstance<T extends WindowConstructor>(node: Node, constr: Constructor<T>): node is T {
let context = this.context;
while(context.parent && context.parent !== context) {
// @ts-ignore
if (node instanceof context[constr.name]) {
return true
}
// @ts-ignore
context = context.parent
}
// @ts-ignore
return node instanceof context[constr.name]
}
private isIgnored(node: Node): boolean {
if (this.isInstance(node, Text)) {
return false;
}
if (!this.isInstance(node, Element)) {
return true;
}
const tag = node.tagName.toUpperCase();
if (tag === 'LINK') {
const rel = node.getAttribute('rel');
const as = node.getAttribute('as');
return !(rel?.includes('stylesheet') || as === "style" || as === "font");
}
return (
tag === 'SCRIPT' ||
tag === 'NOSCRIPT' ||
tag === 'META' ||
tag === 'TITLE' ||
tag === 'BASE'
);
}
private sendNodeAttribute(
id: number,
node: Element,
name: string,
value: string | null,
): void {
if (isSVGElement(node)) {
if (name.substr(0, 6) === 'xlink:') {
name = name.substr(6);
}
if (value === null) {
this.app.send(new RemoveNodeAttribute(id, name));
} else if (name === 'href') {
if (value.length > 1e5) {
value = '';
}
this.app.send(new SetNodeAttributeURLBased(id, name, value, this.app.getBaseHref()));
} else {
this.app.send(new SetNodeAttribute(id, name, value));
}
return;
}
if (
name === 'src' ||
name === 'srcset' ||
name === 'integrity' ||
name === 'crossorigin' ||
name === 'autocomplete' ||
name.substr(0, 2) === 'on'
) {
return;
}
if (
name === 'value' &&
this.isInstance(node, HTMLInputElement) &&
node.type !== 'button' &&
node.type !== 'reset' &&
node.type !== 'submit'
) {
return;
}
if (value === null) {
this.app.send(new RemoveNodeAttribute(id, name));
return;
}
if (name === 'style' || name === 'href' && this.isInstance(node, HTMLLinkElement)) {
this.app.send(new SetNodeAttributeURLBased(id, name, value, this.app.getBaseHref()));
return;
}
if (name === 'href' || value.length > 1e5) {
value = '';
}
this.app.send(new SetNodeAttribute(id, name, value));
}
/* TODO: abstract sanitation */
getInnerTextSecure(el: HTMLElement): string {
const id = this.app.nodes.getID(el)
if (!id) { return '' }
return this.checkObscure(id, el.innerText)
}
private checkObscure(id: number, data: string): string {
if (this.textMasked.has(id)) {
return data.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');
}
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
}
private sendNodeData(id: number, parentElement: Element, data: string): void {
if (this.isInstance(parentElement, HTMLStyleElement) || this.isInstance(parentElement, SVGStyleElement)) {
this.app.send(new SetCSSDataURLBased(id, data, this.app.getBaseHref()));
return;
}
data = this.checkObscure(id, data)
this.app.send(new SetNodeData(id, data));
}
/* end TODO: abstract sanitation */
private bindNode(node: Node): void {
const r = this.app.nodes.registerNode(node);
const id = r[0];
this.recents[id] = r[1] || this.recents[id] || false;
}
private bindTree(node: Node): void {
if (this.isIgnored(node)) {
return;
}
this.bindNode(node);
const walker = document.createTreeWalker(
node,
NodeFilter.SHOW_ELEMENT + NodeFilter.SHOW_TEXT,
{
acceptNode: (node) =>
this.isIgnored(node) || this.app.nodes.getID(node) !== undefined
? NodeFilter.FILTER_REJECT
: NodeFilter.FILTER_ACCEPT,
},
// @ts-ignore
false,
);
while (walker.nextNode()) {
this.bindNode(walker.currentNode);
}
}
private unbindNode(node: Node): void {
const id = this.app.nodes.unregisterNode(node);
if (id !== undefined && this.recents[id] === false) {
this.app.send(new RemoveNode(id));
}
}
private _commitNode(id: number, node: Node): boolean {
const parent = node.parentNode;
let parentID: number | undefined;
if (this.isInstance(node, HTMLHtmlElement)) {
this.indexes[id] = 0
} else {
if (parent === null) {
this.unbindNode(node);
return false;
}
parentID = this.app.nodes.getID(parent);
if (parentID === undefined) {
this.unbindNode(node);
return false;
}
if (!this.commitNode(parentID)) {
this.unbindNode(node);
return false;
}
if (
this.textMasked.has(parentID) ||
(this.isInstance(node, Element) && hasOpenreplayAttribute(node, 'masked'))
) {
this.textMasked.add(id);
}
let sibling = node.previousSibling;
while (sibling !== null) {
const siblingID = this.app.nodes.getID(sibling);
if (siblingID !== undefined) {
this.commitNode(siblingID);
this.indexes[id] = this.indexes[siblingID] + 1;
break;
}
sibling = sibling.previousSibling;
}
if (sibling === null) {
this.indexes[id] = 0;
}
}
const isNew = this.recents[id];
const index = this.indexes[id];
if (index === undefined) {
throw 'commitNode: missing node index';
}
if (isNew === true) {
if (this.isInstance(node, Element)) {
if (parentID !== undefined) {
this.app.send(new
CreateElementNode(
id,
parentID,
index,
node.tagName,
isSVGElement(node),
),
);
}
for (let i = 0; i < node.attributes.length; i++) {
const attr = node.attributes[i];
this.sendNodeAttribute(id, node, attr.nodeName, attr.value);
}
if (this.isInstance(node, HTMLIFrameElement) &&
(this.options.captureIFrames || node.getAttribute("data-openreplay-capture"))) {
this.handleIframe(node);
}
} else if (this.isInstance(node, Text)) {
// for text node id != 0, hence parentID !== undefined and parent is Element
this.app.send(new CreateTextNode(id, parentID as number, index));
this.sendNodeData(id, parent as Element, node.data);
}
return true;
}
if (isNew === false && parentID !== undefined) {
this.app.send(new MoveNode(id, parentID, index));
}
const attr = this.attributesList[id];
if (attr !== undefined) {
if (!this.isInstance(node, Element)) {
throw 'commitNode: node is not an element';
}
for (const name of attr) {
this.sendNodeAttribute(id, node, name, node.getAttribute(name));
}
}
if (this.textSet.has(id)) {
if (!this.isInstance(node, 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);
}
return true;
}
private commitNode(id: number): boolean {
const node = this.app.nodes.getNode(id);
if (node === undefined) {
return false;
}
const cmt = this.commited[id];
if (cmt !== undefined) {
return cmt;
}
return (this.commited[id] = this._commitNode(id, node));
}
private commitNodes(): void {
let node;
for (let id = 0; id < this.recents.length; id++) {
this.commitNode(id);
if (this.recents[id] === true && (node = this.app.nodes.getNode(id))) {
this.app.nodes.callNodeCallbacks(node);
}
}
this.clear();
}
private iframeObservers: Observer[] = [];
private handleIframe(iframe: HTMLIFrameElement): void {
let context: Window | null = null
const handle = this.app.safe(() => {
const id = this.app.nodes.getID(iframe)
if (id === undefined) { return }
if (iframe.contentWindow === context) { return }
context = iframe.contentWindow as Window | null;
if (!context) { return }
const observer = new Observer(this.app, this.options, context)
this.iframeObservers.push(observer)
observer.observeIframe(id, context)
})
this.app.attachEventListener(iframe, "load", handle)
handle()
}
// TODO: abstract common functionality, separate FrameObserver
private observeIframe(id: number, context: Window) {
const doc = context.document;
this.observer.observe(doc, {
childList: true,
attributes: true,
characterData: true,
subtree: true,
attributeOldValue: false,
characterDataOldValue: false,
});
this.bindTree(doc.documentElement);
const docID = this.app.nodes.getID(doc.documentElement);
if (docID === undefined) {
console.log("Wrong")
return;
}
this.app.send(CreateIFrameDocument(id,docID));
this.commitNodes();
}
observe(): void {
this.observer.observe(this.context.document, {
childList: true,
attributes: true,
characterData: true,
subtree: true,
attributeOldValue: false,
characterDataOldValue: false,
});
this.app.send(new CreateDocument());
this.bindTree(this.context.document.documentElement);
this.commitNodes();
}
disconnect(): void {
this.iframeObservers.forEach(o => o.disconnect());
this.iframeObservers = [];
this.observer.disconnect();
this.clear();
}
}

View file

@ -0,0 +1,19 @@
import Observer from "./observer.js";
import { CreateIFrameDocument } from "../../../messages/index.js";
export default class IFrameObserver extends Observer {
observe(iframe: HTMLIFrameElement) {
const doc = iframe.contentDocument;
const hostID = this.app.nodes.getID(iframe);
if (!doc || hostID === undefined) { return } //log TODO common app.logger
// 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;
}
this.app.send(CreateIFrameDocument(hostID, docID));
});
}
}

View file

@ -0,0 +1,353 @@
import { hasOpenreplayAttribute } from "../../utils.js";
import {
RemoveNodeAttribute,
SetNodeAttribute,
SetNodeAttributeURLBased,
SetCSSDataURLBased,
SetNodeData,
CreateTextNode,
CreateElementNode,
MoveNode,
RemoveNode,
} from "../../../messages/index.js";
import App from "../index.js";
import { isInstance, inDocument } from "../context.js";
function isSVGElement(node: Element): node is SVGElement {
return node.namespaceURI === 'http://www.w3.org/2000/svg';
}
function isIgnored(node: Node): boolean {
if (isInstance(node, Text)) {
return false;
}
if (!isInstance(node, Element)) {
return true;
}
const tag = node.tagName.toUpperCase();
if (tag === 'LINK') {
const rel = node.getAttribute('rel');
const as = node.getAttribute('as');
return !(rel?.includes('stylesheet') || as === "style" || as === "font");
}
return (
tag === 'SCRIPT' ||
tag === 'NOSCRIPT' ||
tag === 'META' ||
tag === 'TITLE' ||
tag === 'BASE'
);
}
function isRootNode(node: Node): boolean {
return isInstance(node, Document) || isInstance(node, ShadowRoot);
}
function isObservable(node: Node): boolean {
if (isRootNode(node)) {
return true;
}
return !isIgnored(node);
}
export default abstract class Observer {
private readonly observer: MutationObserver;
private readonly commited: Array<boolean | undefined> = [];
private readonly recents: Array<boolean | undefined> = [];
private readonly myNodes: Array<boolean | undefined> = [];
private readonly indexes: Array<number> = [];
private readonly attributesList: Array<Set<string> | undefined> = [];
private readonly textSet: Set<number> = new Set();
private readonly inUpperContext: boolean;
constructor(protected readonly app: App, protected readonly context: Window = window) {
this.inUpperContext = context.parent === context //TODO: get rid of context here
this.observer = new MutationObserver(
this.app.safe((mutations) => {
for (const mutation of mutations) {
const target = mutation.target;
const type = mutation.type;
if (!isObservable(target) || !inDocument(target)) {
continue;
}
if (type === 'childList') {
for (let i = 0; i < mutation.removedNodes.length; i++) {
this.bindTree(mutation.removedNodes[i]);
}
for (let i = 0; i < mutation.addedNodes.length; i++) {
this.bindTree(mutation.addedNodes[i]);
}
continue;
}
const id = this.app.nodes.getID(target);
if (id === undefined) {
continue;
}
if (id >= this.recents.length) { // TODO: something more convinient
this.recents[id] = undefined;
}
if (type === 'attributes') {
const name = mutation.attributeName;
if (name === null) {
continue;
}
let attr = this.attributesList[id];
if (attr === undefined) {
this.attributesList[id] = attr = new Set();
}
attr.add(name);
continue;
}
if (type === 'characterData') {
this.textSet.add(id);
continue;
}
}
this.commitNodes();
}),
);
}
private clear(): void {
this.commited.length = 0;
this.recents.length = 0;
this.indexes.length = 1;
this.attributesList.length = 0;
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);
}
if (value === null) {
this.app.send(new RemoveNodeAttribute(id, name));
} else if (name === 'href') {
if (value.length > 1e5) {
value = '';
}
this.app.send(new SetNodeAttributeURLBased(id, name, value, this.app.getBaseHref()));
} else {
this.app.send(new SetNodeAttribute(id, name, value));
}
return;
}
if (
name === 'src' ||
name === 'srcset' ||
name === 'integrity' ||
name === 'crossorigin' ||
name === 'autocomplete' ||
name.substr(0, 2) === 'on'
) {
return;
}
if (
name === 'value' &&
isInstance(node, HTMLInputElement) &&
node.type !== 'button' &&
node.type !== 'reset' &&
node.type !== 'submit'
) {
return;
}
if (value === null) {
this.app.send(new RemoveNodeAttribute(id, name));
return;
}
if (name === 'style' || name === 'href' && isInstance(node, HTMLLinkElement)) {
this.app.send(new SetNodeAttributeURLBased(id, name, value, this.app.getBaseHref()));
return;
}
if (name === 'href' || value.length > 1e5) {
value = '';
}
this.app.send(new SetNodeAttribute(id, name, value));
}
private sendNodeData(id: number, parentElement: Element, data: string): void {
if (isInstance(parentElement, HTMLStyleElement) || isInstance(parentElement, SVGStyleElement)) {
this.app.send(new SetCSSDataURLBased(id, data, this.app.getBaseHref()));
return;
}
data = this.app.sanitizer.sanitize(id, data)
this.app.send(new SetNodeData(id, data));
}
private bindNode(node: Node): void {
const r = this.app.nodes.registerNode(node);
const id = r[0];
this.recents[id] = r[1] || this.recents[id] || false;
this.myNodes[id] = true;
}
private bindTree(node: Node): void {
if (!isObservable(node)) {
return
}
this.bindNode(node);
const walker = document.createTreeWalker(
node,
NodeFilter.SHOW_ELEMENT + NodeFilter.SHOW_TEXT,
{
acceptNode: (node) =>
isIgnored(node) || this.app.nodes.getID(node) !== undefined
? NodeFilter.FILTER_REJECT
: NodeFilter.FILTER_ACCEPT,
},
// @ts-ignore
false,
);
while (walker.nextNode()) {
this.bindNode(walker.currentNode);
}
}
private unbindNode(node: Node): void {
const id = this.app.nodes.unregisterNode(node);
if (id !== undefined && this.recents[id] === false) {
this.app.send(new RemoveNode(id));
}
}
private _commitNode(id: number, node: Node): boolean {
if (isRootNode(node)) {
return true;
}
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)
// TODO: Clean the logic (though now it workd fine)
if (!isInstance(node, HTMLHtmlElement) || !this.inUpperContext) {
if (parent === null) {
this.unbindNode(node);
return false;
}
parentID = this.app.nodes.getID(parent);
if (parentID === undefined) {
this.unbindNode(node);
return false;
}
if (!this.commitNode(parentID)) {
this.unbindNode(node);
return false;
}
this.app.sanitizer.handleNode(id, parentID, node);
}
let sibling = node.previousSibling;
while (sibling !== null) {
const siblingID = this.app.nodes.getID(sibling);
if (siblingID !== undefined) {
this.commitNode(siblingID);
this.indexes[id] = this.indexes[siblingID] + 1;
break;
}
sibling = sibling.previousSibling;
}
if (sibling === null) {
this.indexes[id] = 0; //
}
const isNew = this.recents[id];
const index = this.indexes[id];
if (index === undefined) {
throw 'commitNode: missing node index';
}
if (isNew === true) {
if (isInstance(node, Element)) {
if (parentID !== undefined) {
this.app.send(new
CreateElementNode(
id,
parentID,
index,
node.tagName,
isSVGElement(node),
),
);
}
for (let i = 0; i < node.attributes.length; i++) {
const attr = node.attributes[i];
this.sendNodeAttribute(id, node, attr.nodeName, attr.value);
}
} else if (isInstance(node, Text)) {
// for text node id != 0, hence parentID !== undefined and parent is Element
this.app.send(new CreateTextNode(id, parentID as number, index));
this.sendNodeData(id, parent as Element, node.data);
}
return true;
}
if (isNew === false && parentID !== undefined) {
this.app.send(new MoveNode(id, parentID, index));
}
const attr = this.attributesList[id];
if (attr !== undefined) {
if (!isInstance(node, Element)) {
throw 'commitNode: node is not an element';
}
for (const name of attr) {
this.sendNodeAttribute(id, node, name, node.getAttribute(name));
}
}
if (this.textSet.has(id)) {
if (!isInstance(node, 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);
}
return true;
}
private commitNode(id: number): boolean {
const node = this.app.nodes.getNode(id);
if (node === undefined) {
return false;
}
const cmt = this.commited[id];
if (cmt !== undefined) {
return cmt;
}
return (this.commited[id] = this._commitNode(id, node));
}
private commitNodes(): void {
let node;
for (let id = 0; id < this.recents.length; id++) {
// TODO: make things/logic nice here.
// commit required in any case if recents[id] true or false (in case of unbinding) or undefined (in case of attr change).
if (!this.myNodes[id]) { continue }
this.commitNode(id);
if (this.recents[id] === true && (node = this.app.nodes.getNode(id))) {
this.app.nodes.callNodeCallbacks(node);
}
}
this.clear();
}
// ISSSUE
protected observeRoot(node: Node, beforeCommit: (id?: number) => unknown, nodeToBind: Node = node) {
this.observer.observe(node, {
childList: true,
attributes: true,
characterData: true,
subtree: true,
attributeOldValue: false,
characterDataOldValue: false,
});
this.bindTree(nodeToBind);
beforeCommit(this.app.nodes.getID(node))
this.commitNodes();
}
disconnect(): void {
this.observer.disconnect();
this.clear();
this.myNodes.length = 0;
}
}

View file

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

View file

@ -0,0 +1,98 @@
import Observer from "./observer.js";
import { isInstance } from "../context.js";
import type { Window } from "../context.js";
import IFrameObserver from "./iframe_observer.js";
import ShadowRootObserver from "./shadow_root_observer.js";
import { CreateDocument } from "../../../messages/index.js";
import App from "../index.js";
import { IN_BROWSER } from '../../utils.js'
export interface Options {
captureIFrames: boolean
}
const attachShadowNativeFn = IN_BROWSER ? Element.prototype.attachShadow : ()=>new ShadowRoot();
export default class TopObserver extends Observer {
private readonly options: Options;
constructor(app: App, options: Partial<Options>) {
super(app);
this.options = Object.assign({
captureIFrames: false
}, options);
// IFrames
this.app.nodes.attachNodeCallback(node => {
if (isInstance(node, HTMLIFrameElement) &&
(this.options.captureIFrames || node.getAttribute("data-openreplay-capture"))
) {
this.handleIframe(node)
}
})
// ShadowDOM
this.app.nodes.attachNodeCallback(node => {
if (isInstance(node, Element) && node.shadowRoot !== null) {
this.handleShadowRoot(node.shadowRoot)
}
})
}
private iframeObservers: IFrameObserver[] = [];
private handleIframe(iframe: HTMLIFrameElement): void {
let context: Window | null = null
const handle = this.app.safe(() => {
const id = this.app.nodes.getID(iframe)
if (id === undefined) { return } //log
if (iframe.contentWindow === context) { return } //Does this happen frequently?
context = iframe.contentWindow as Window | null;
if (!context) { return }
const observer = new IFrameObserver(this.app, context)
this.iframeObservers.push(observer)
observer.observe(iframe)
})
this.app.attachEventListener(iframe, "load", handle)
handle()
}
private shadowRootObservers: ShadowRootObserver[] = []
private handleShadowRoot(shRoot: ShadowRoot) {
const observer = new ShadowRootObserver(this.app, this.context)
this.shadowRootObservers.push(observer)
observer.observe(shRoot.host)
}
observe(): void {
// Protection from several subsequent calls?
const observer = this;
Element.prototype.attachShadow = function() {
const shadow = attachShadowNativeFn.apply(this, arguments)
observer.handleShadowRoot(shadow)
return shadow
}
// Can observe documentElement (<html>) here, because it is not supposed to be changing.
// However, it is possible in some exotic cases and may cause an ignorance of the newly created <html>
// In this case context.document have to be observed, but this will cause
// the change in the re-player behaviour caused by CreateDocument message:
// the 0-node ("fRoot") will become #document rather than documentElement as it is now.
// Alternatively - observe(#document) then bindNode(documentElement)
this.observeRoot(this.context.document, () => {
this.app.send(new CreateDocument())
}, this.context.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()
}
}

View file

@ -0,0 +1,66 @@
import { stars, hasOpenreplayAttribute } from "../utils.js";
import App from "./index.js";
import { isInstance } from "./context.js";
export interface Options {
obscureTextEmails: boolean;
obscureTextNumbers: boolean;
}
export default class Sanitizer {
private readonly masked: Set<number> = new Set();
private readonly options: Options;
constructor(private readonly app: App, options: Partial<Options>) {
this.options = Object.assign({
obscureTextEmails: true,
obscureTextNumbers: false,
}, options);
}
handleNode(id: number, parentID: number, node: Node) {
if (
this.masked.has(parentID) ||
(isInstance(node, Element) && hasOpenreplayAttribute(node, 'masked'))
) {
this.masked.add(id);
}
}
sanitize(id: number, data: string): string {
if (this.masked.has(id)) {
// TODO: is it the best place to put trim() ? Might trimmed spaces be considered in layout in certain cases?
return data.trim().replace(
/[^\f\n\r\t\v\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]/g,
'█',
);
}
if (this.options.obscureTextNumbers) {
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
}
isMasked(id: number): boolean {
return this.masked.has(id);
}
getInnerTextSecure(el: HTMLElement): string {
const id = this.app.nodes.getID(el)
if (!id) { return '' }
return this.sanitize(id, el.innerText)
}
clear(): void {
this.masked.clear();
}
}

View file

@ -19,14 +19,15 @@ import Longtasks from "./modules/longtasks.js";
import CSSRules from "./modules/cssrules.js";
import { IN_BROWSER, deprecationWarn, DOCS_HOST } from "./utils.js";
import { Options as AppOptions } from "./app/index.js";
import { Options as ConsoleOptions } from "./modules/console.js";
import { Options as ExceptionOptions } from "./modules/exception.js";
import { Options as InputOptions } from "./modules/input.js";
import { Options as PerformanceOptions } from "./modules/performance.js";
import { Options as TimingOptions } from "./modules/timing.js";
export type { OnStartInfo } 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 { OnStartInfo } from './app/index.js';
export type Options = Partial<
AppOptions & ConsoleOptions & ExceptionOptions & InputOptions & PerformanceOptions & TimingOptions
@ -35,6 +36,7 @@ export type Options = Partial<
projectKey: string;
sessionToken?: string;
respectDoNotTrack?: boolean;
autoResetOnWindowOpen?: boolean;
// dev only
__DISABLE_SECURE_MODE?: boolean;
};
@ -84,7 +86,7 @@ export default class API {
(navigator.doNotTrack == '1'
// @ts-ignore
|| window.doNotTrack == '1');
this.app = doNotTrack ||
const app = this.app = doNotTrack ||
!('Map' in window) ||
!('Set' in window) ||
!('MutationObserver' in window) ||
@ -95,20 +97,35 @@ export default class API {
!('Worker' in window)
? null
: new App(options.projectKey, options.sessionToken, options);
if (this.app !== null) {
Viewport(this.app);
CSSRules(this.app);
Connection(this.app);
Console(this.app, options);
Exception(this.app, options);
Img(this.app);
Input(this.app, options);
Mouse(this.app);
Timing(this.app, options);
Performance(this.app, options);
Scroll(this.app);
Longtasks(this.app);
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);
Longtasks(app);
(window as any).__OPENREPLAY__ = this;
if (options.autoResetOnWindowOpen) {
const wOpen = window.open;
app.attachStartCallback(() => {
// @ts-ignore ?
window.open = function(...args) {
app.resetNextPageSession(true)
wOpen.call(window, ...args)
app.resetNextPageSession(false)
}
})
app.attachStopCallback(() => {
window.open = wOpen;
})
}
} else {
console.log("OpenReplay: browser doesn't support API required for tracking or doNotTrack is set to 1.")
const req = new XMLHttpRequest();
@ -140,7 +157,7 @@ export default class API {
return this.isActive();
}
start() /*: Promise<OnStartInfo>*/ {
start(startOpts?: StartOptions) : Promise<OnStartInfo> {
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.");
@ -148,7 +165,7 @@ export default class API {
if (this.app === null) {
return Promise.reject("Browser doesn't support required api, or doNotTrack is active.");
}
return this.app.start();
return this.app.start(startOpts);
}
stop(): void {
if (this.app === null) {

View file

@ -50,7 +50,13 @@ export function getExceptionMessageFromEvent(e: ErrorEvent | PromiseRejectionEve
if (e.reason instanceof Error) {
return getExceptionMessage(e.reason, [])
} else {
return new JSException('Unhandled Promise Rejection', String(e.reason), '[]');
let message: string;
try {
message = JSON.stringify(e.reason)
} catch(_) {
message = String(e.reason)
}
return new JSException('Unhandled Promise Rejection', message, '[]');
}
}
return null;

View file

@ -1,8 +1,21 @@
import { timestamp, isURL } from "../utils.js";
import App from "../app/index.js";
import { ResourceTiming, SetNodeAttributeURLBased } from "../../messages/index.js";
import { ResourceTiming, SetNodeAttributeURLBased, SetNodeAttribute } from "../../messages/index.js";
const PLACEHOLDER_SRC = "https://static.openreplay.com/tracker/placeholder.jpeg";
export default function (app: App): void {
function sendPlaceholder(id: number, node: HTMLImageElement): void {
app.send(new SetNodeAttribute(id, "src", PLACEHOLDER_SRC))
const { width, height } = node.getBoundingClientRect();
if (!node.hasAttribute("width")){
app.send(new SetNodeAttribute(id, "width", String(width)))
}
if (!node.hasAttribute("height")){
app.send(new SetNodeAttribute(id, "height", String(height)))
}
}
const sendImgSrc = app.safe(function (this: HTMLImageElement): void {
const id = app.nodes.getID(this);
if (id === undefined) {
@ -16,7 +29,9 @@ export default function (app: App): void {
if (src != null && isURL(src)) { // TODO: How about relative urls ? Src type is null sometimes.
app.send(new ResourceTiming(timestamp(), 0, 0, 0, 0, 0, src, 'img'));
}
} else if (src.length < 1e5) {
} else if (src.length >= 1e5 || app.sanitizer.isMasked(id)) {
sendPlaceholder(id, this)
} else {
app.send(new SetNodeAttributeURLBased(id, 'src', src, app.getBaseHref()));
}
});

View file

@ -2,7 +2,12 @@ import { normSpaces, IN_BROWSER, getLabelAttribute, hasOpenreplayAttribute } fro
import App from "../app/index.js";
import { SetInputTarget, SetInputValue, SetInputChecked } from "../../messages/index.js";
function isInput(node: any): node is HTMLInputElement {
// TODO: take into consideration "contenteditable" attribute
type TextEditableElement = HTMLInputElement | HTMLTextAreaElement
function isTextEditable(node: any): node is TextEditableElement {
if (node instanceof HTMLTextAreaElement) {
return true;
}
if (!(node instanceof HTMLInputElement)) {
return false;
}
@ -16,6 +21,7 @@ function isInput(node: any): node is HTMLInputElement {
type === 'range'
);
}
function isCheckable(node: any): node is HTMLInputElement {
if (!(node instanceof HTMLInputElement)) {
return false;
@ -25,7 +31,7 @@ function isCheckable(node: any): node is HTMLInputElement {
}
const labelElementFor: (
node: HTMLInputElement,
node: TextEditableElement,
) => HTMLLabelElement | undefined =
IN_BROWSER && 'labels' in HTMLInputElement.prototype
? (node): HTMLLabelElement | undefined => {
@ -56,7 +62,7 @@ const labelElementFor: (
}
};
export function getInputLabel(node: HTMLInputElement): string {
export function getInputLabel(node: TextEditableElement): string {
let label = getLabelAttribute(node);
if (label === null) {
const labelElement = labelElementFor(node);
@ -89,13 +95,13 @@ export default function (app: App, opts: Partial<Options>): void {
},
opts,
);
function sendInputTarget(id: number, node: HTMLInputElement): void {
function sendInputTarget(id: number, node: TextEditableElement): void {
const label = getInputLabel(node);
if (label !== '') {
app.send(new SetInputTarget(id, label));
}
}
function sendInputValue(id: number, node: HTMLInputElement): void {
function sendInputValue(id: number, node: TextEditableElement): void {
let value = node.value;
let inputMode: InputMode = options.defaultInputMode;
if (node.type === 'password' || hasOpenreplayAttribute(node, 'hidden')) {
@ -136,7 +142,7 @@ export default function (app: App, opts: Partial<Options>): void {
app.ticker.attach((): void => {
inputValues.forEach((value, id) => {
const node = app.nodes.getNode(id);
if (!isInput(node)) {
if (!isTextEditable(node)) {
inputValues.delete(id);
return;
}
@ -169,7 +175,7 @@ export default function (app: App, opts: Partial<Options>): void {
if (id === undefined) {
return;
}
if (isInput(node)) {
if (isTextEditable(node)) {
inputValues.set(id, node.value);
sendInputValue(id, node);
return;

View file

@ -92,7 +92,7 @@ export default function (app: App): void {
(target as HTMLElement).onclick != null ||
target.getAttribute('role') === 'button'
) {
const label: string = app.observer.getInnerTextSecure(target as HTMLElement);
const label: string = app.sanitizer.getInnerTextSecure(target as HTMLElement);
return normSpaces(label).slice(0, 100);
}
return '';

View file

@ -47,6 +47,7 @@ function sendBatch(batch: Uint8Array):void {
return; // happens simultaneously with onerror TODO: clear codeflow
}
if (this.status >= 400) { // TODO: test workflow. After 400+ it calls /start for some reason
busy = false;
reset();
sendQueue.length = 0;
if (this.status === 401) { // Unauthorised (Token expired)