UNPKG

@trynova-ai/shadow-js

Version:

A TypeScript library for user session tracking.

192 lines (162 loc) 7.44 kB
import { Shadow } from '../shadow'; // Define the structure for a scrubbing rule type ScrubRule = { type: 'id' | 'regex'; match: string; method: 'mask' | 'randomize'; }; export class BrowserPlugin { private scrubRules: ScrubRule[] = []; constructor(scrubRules: ScrubRule[] = []) { this.scrubRules = scrubRules; } setup(shadow: Shadow) { const listeners = [ { type: 'click', handler: (event: Event) => this.handleEvent('click', event, shadow) }, { type: 'change', handler: (event: Event) => this.handleEvent('change', event, shadow) }, { type: 'submit', handler: (event: Event) => this.handleEvent('submit', event, shadow) }, { type: 'load', handler: (event: Event) => this.handleEvent('load', event, shadow) } ]; this.setupListeners(listeners); } private setupListeners(events: { type: string, handler: EventListener }[]) { events.forEach(event => { window.addEventListener(event.type, event.handler, { capture: true, passive: true }); }); } private handleEvent(eventType: string, event: Event, shadow: Shadow) { try { const target = this.resolveTargetElement(event.target as Element); const payload = this.eventPayload(eventType, target); // Apply scrubbing to the event payload const scrubbedPayload = this.applyScrubbingRules(payload); shadow.capture(scrubbedPayload); } catch (error) { console.error(`Error handling ${eventType} event:`, error); } } private eventPayload(eventType: string, target: Element | null) { try { const payload: any = { action: eventType.toUpperCase(), page: window.location.href, previousPage: document.referrer ? document.referrer : '', timestamp: new Date().toISOString(), element: target ? { tag: target.tagName || '', css: target instanceof HTMLElement ? target.style.cssText : '', attributes: this.getAttributes(target), innerText: target instanceof HTMLElement ? target.innerText : '', } : null, }; // Check if the event is a change event if (eventType === 'change' && target instanceof HTMLInputElement) { payload.action = 'INPUT'; } else if (eventType === 'change' && target instanceof HTMLSelectElement) { payload.action = 'SELECT'; } else if (eventType === 'change' && target instanceof HTMLTextAreaElement) { payload.action = 'INPUT'; } return payload; } catch (error) { console.error(`Error creating event payload for ${eventType} event:`, error); return null; } } private applyScrubbingRules(payload: any) { if (!payload.element) return payload; const element = payload.element; this.scrubRules.forEach((rule) => { if (rule.type === 'id' && element.attributes.id === rule.match) { // For ID-based rules, scrub innerText and the "value" attribute this.scrubData(element, rule, 'innerText'); if (element.attributes['value']) { this.scrubData(element, rule, 'value', true); } } else if (rule.type === 'regex') { const regex = new RegExp(rule.match); // For regex-based rules, scrub innerText and any matching attribute values if (regex.test(element.innerText || '')) { this.scrubData(element, rule, 'innerText'); } Object.keys(element.attributes).forEach((attrKey) => { const attrValue = element.attributes[attrKey]; if (regex.test(attrValue)) { this.scrubData(element, rule, attrKey, true); } }); } }); return payload; } private scrubData(element: any, rule: ScrubRule, key: string, isAttribute: boolean = false) { if (rule.method === 'mask') { if (isAttribute) { element.attributes[key] = '****'; // Mask sensitive attribute value } else { element[key] = '****'; // Mask sensitive innerText } } else if (rule.method === 'randomize') { const randomValue = Math.random().toString(36).substr(2, 10); if (isAttribute) { element.attributes[key] = randomValue; // Randomize sensitive attribute value } else { element[key] = randomValue; // Randomize sensitive innerText } } } private resolveTargetElement(target: Element): Element { try { let targetElement: Element | null = target; // Traverse up the DOM tree to find the nearest parent that is either a <button> or has role="button" while (targetElement && targetElement !== document.body) { if (targetElement.tagName.toLowerCase() === 'button' || targetElement.getAttribute('role') === 'button') { break; } targetElement = targetElement.parentElement; } // If no button was found or the element doesn't have id/class, find the nearest parent with id or class if (!targetElement?.id && !this.getClassNames(targetElement)) { targetElement = target; // Start again from the original clicked element while (targetElement && targetElement !== document.body) { if (targetElement.id || this.getClassNames(targetElement)) { break; // Found a parent with an id or class, use this } targetElement = targetElement.parentElement; } } return targetElement || target; } catch (error) { console.error('Error resolving target element:', error); return target; } } private getAttributes(element: Element | null): { [key: string]: string } { try { const attrs: { [key: string]: string } = {}; if (element) { for (let i = 0; i < element.attributes.length; i++) { attrs[element.attributes[i].name] = element.attributes[i].value; } } return attrs; } catch (error) { console.error('Error getting element attributes:', error); return {}; } } private getClassNames(element: Element | null): string { try { if (!element) return ''; if (element instanceof HTMLElement) { return element.className; } else if (element instanceof SVGElement && typeof element.className === 'object') { return element.className.baseVal; // SVG className is an object with baseVal } return ''; } catch (error) { console.error('Error getting element class names:', error); return ''; } } }