UNPKG

@scrubbe-auth/user-tracker

Version:
1,093 lines (1,083 loc) 35.8 kB
class DOMUtils { static getSelector(element) { if (element.id) { return `#${element.id}`; } if (element.className) { const classes = element.className.split(' ').filter(Boolean); if (classes.length > 0) { return `.${classes.join('.')}`; } } const tagName = element.tagName.toLowerCase(); const parent = element.parentElement; if (!parent) { return tagName; } const siblings = Array.from(parent.children); const index = siblings.indexOf(element); return `${this.getSelector(parent)} > ${tagName}:nth-child(${index + 1})`; } static getElementText(element) { // Get visible text content const text = element.textContent?.trim(); return text && text.length > 0 ? text : null; } static getFormId(form) { return form.id || form.name || this.getSelector(form); } static isElementVisible(element) { const rect = element.getBoundingClientRect(); const style = window.getComputedStyle(element); return (rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none' && style.opacity !== '0'); } static getElementPosition(element) { const rect = element.getBoundingClientRect(); return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; } } class EventUtils { static throttle(func, delay) { let timeoutId = null; let lastExecTime = 0; return (...args) => { const currentTime = Date.now(); if (currentTime - lastExecTime > delay) { func(...args); lastExecTime = currentTime; } else { if (timeoutId) { clearTimeout(timeoutId); } timeoutId = setTimeout(() => { func(...args); lastExecTime = Date.now(); }, delay - (currentTime - lastExecTime)); } }; } static debounce(func, delay) { let timeoutId = null; return (...args) => { if (timeoutId) { clearTimeout(timeoutId); } timeoutId = setTimeout(() => { func(...args); }, delay); }; } static once(func) { let called = false; let result; return (...args) => { if (!called) { called = true; result = func(...args); return result; } return undefined; }; } } class Loggers { constructor(debug = false, logLevel = 'info', outputs = [new ConsoleOutput()]) { this.debugs = debug; this.logLevel = logLevel; this.outputs = outputs; this.context = {}; } setContext(context) { this.context = { ...this.context, ...context }; } clearContext() { this.context = {}; } debug(message, ...args) { if (this.debugs && this.shouldLog('debug')) { this.log('debug', message, args); } } info(message, ...args) { if (this.shouldLog('info')) { this.log('info', message, args); } } warn(message, ...args) { if (this.shouldLog('warn')) { this.log('warn', message, args); } } error(message, ...args) { if (this.shouldLog('error')) { this.log('error', message, args); } } shouldLog(level) { const levels = { debug: 0, info: 1, warn: 2, error: 3 }; return levels[level] >= levels[this.logLevel]; } log(level, message, args) { const logEntry = { timestamp: new Date().toISOString(), level, message, args, context: this.context }; this.outputs.forEach(output => { try { output.write(logEntry); } catch (error) { console.error('Failed to write log:', error); } }); } // Create child logger with additional context child(context) { const child = new Loggers(this.debugs, this.logLevel, this.outputs); child.setContext({ ...this.context, ...context }); return child; } // Add output addOutput(output) { this.outputs.push(output); } // Remove output removeOutput(output) { const index = this.outputs.indexOf(output); if (index > -1) { this.outputs.splice(index, 1); } } } class ConsoleOutput { write(entry) { const prefix = `[${entry.timestamp}] [${entry.level.toUpperCase()}] [Scrubbe Analytics]`; const contextStr = Object.keys(entry.context).length > 0 ? ` ${JSON.stringify(entry.context)}` : ''; const fullMessage = `${prefix}${contextStr} ${entry.message}`; switch (entry.level) { case 'debug': console.debug(fullMessage, ...entry.args); break; case 'info': console.info(fullMessage, ...entry.args); break; case 'warn': console.warn(fullMessage, ...entry.args); break; case 'error': console.error(fullMessage, ...entry.args); break; } } } class BufferedOutput { constructor(maxSize = 1000) { this.buffer = []; this.maxSize = maxSize; } write(entry) { this.buffer.push(entry); if (this.buffer.length > this.maxSize) { this.buffer.shift(); } } getBuffer() { return [...this.buffer]; } clear() { this.buffer = []; } flush(output) { this.buffer.forEach(entry => output.write(entry)); this.clear(); } } class FileOutput { constructor(filename) { this.filename = filename; } write(entry) { // This would write to file in Node.js environment // For browser environment, could use IndexedDB or send to server if (typeof window === 'undefined' && typeof require !== 'undefined') { try { const fs = require('fs'); const logLine = JSON.stringify(entry) + '\n'; fs.appendFileSync(this.filename, logLine); } catch (error) { console.error('Failed to write to log file:', error); } } } } class PageViewTracker { constructor(config) { this.config = config; } track(page, properties = {}) { const activity = { type: 'pageview', timestamp: Date.now(), data: { page, url: typeof window !== 'undefined' ? window.location.href : page, title: typeof document !== 'undefined' ? document.title : undefined, referrer: typeof document !== 'undefined' ? document.referrer : undefined, search: typeof window !== 'undefined' ? window.location.search : undefined, hash: typeof window !== 'undefined' ? window.location.hash : undefined, ...properties } }; this.lastPage = page; return activity; } getLastPage() { return this.lastPage; } } class ClickTracker { constructor(config) { this.clickHistory = []; this.config = config; } handleClick(event) { const target = event.target; if (!target) return null; // Check blacklist/whitelist if (!this.shouldTrackElement(target)) { return null; } const activity = this.createClickActivity(event, target); // Track rage clicks if (this.config.clickTracking?.trackRageClicks) { this.trackRageClicks(target); } // Track dead clicks if (this.config.clickTracking?.trackDeadClicks) { this.trackDeadClicks(event, target); } return activity; } trackManual(selector, properties = {}) { return { type: 'click', timestamp: Date.now(), data: { selector, manual: true, ...properties } }; } createClickActivity(event, target) { const selector = DOMUtils.getSelector(target); const text = DOMUtils.getElementText(target); return { type: 'click', timestamp: Date.now(), data: { selector, tagName: target.tagName.toLowerCase(), text: text?.substring(0, 100), // Limit text length href: target.getAttribute('href'), id: target.id, className: target.className, x: event.clientX, y: event.clientY, pageX: event.pageX, pageY: event.pageY, button: event.button, ctrlKey: event.ctrlKey, shiftKey: event.shiftKey, altKey: event.altKey } }; } shouldTrackElement(element) { const selector = DOMUtils.getSelector(element); // Check whitelist first if (this.config.clickTracking?.whitelistSelectors?.length) { return this.config.clickTracking.whitelistSelectors.some(pattern => this.matchesSelector(selector, pattern)); } // Check blacklist if (this.config.clickTracking?.blacklistSelectors?.length) { return !this.config.clickTracking.blacklistSelectors.some(pattern => this.matchesSelector(selector, pattern)); } return true; } matchesSelector(selector, pattern) { try { return selector.includes(pattern) || selector.match(new RegExp(pattern)); } catch { return selector.includes(pattern); } } trackRageClicks(element) { const now = Date.now(); const rageWindow = 2000; // 2 seconds const rageThreshold = 3; // 3 clicks // Add to history this.clickHistory.push({ timestamp: now, element }); // Clean old entries this.clickHistory = this.clickHistory.filter(click => now - click.timestamp < rageWindow); // Check for rage clicks on same element const sameElementClicks = this.clickHistory.filter(click => click.element === element); if (sameElementClicks.length >= rageThreshold) { const activity = { type: 'rage_click', timestamp: now, data: { selector: DOMUtils.getSelector(element), clickCount: sameElementClicks.length, timeWindow: rageWindow } }; this.onClick?.(activity); } } trackDeadClicks(event, target) { // A dead click is a click that doesn't result in a page change or visible action let hasAction = false; // Check for immediate actions const checkForAction = () => { // Check if page changed const currentUrl = window.location.href; setTimeout(() => { if (window.location.href !== currentUrl) { hasAction = true; } }, 100); // Check for CSS changes (simplified) const originalDisplay = target.style.display; setTimeout(() => { if (target.style.display !== originalDisplay) { hasAction = true; } }, 100); }; checkForAction(); // Check after a delay setTimeout(() => { if (!hasAction) { const activity = { type: 'dead_click', timestamp: Date.now(), data: { selector: DOMUtils.getSelector(target), tagName: target.tagName.toLowerCase(), x: event.clientX, y: event.clientY } }; this.onClick?.(activity); } }, 500); } } class ScrollTracker { constructor(config) { this.maxScrollDepth = 0; this.scrollCheckpoints = new Set(); this.isThrottled = false; this.config = config; } handleScroll() { if (this.isThrottled) return null; const scrollDepth = this.calculateScrollDepth(); // Update max scroll depth if (scrollDepth > this.maxScrollDepth) { this.maxScrollDepth = scrollDepth; } // Check if we've hit a new checkpoint const threshold = this.config.scrollThreshold || 25; const checkpoint = Math.floor(scrollDepth / threshold) * threshold; if (checkpoint > 0 && !this.scrollCheckpoints.has(checkpoint)) { this.scrollCheckpoints.add(checkpoint); // Throttle scroll events this.throttle(); return this.createScrollActivity(scrollDepth, checkpoint); } return null; } trackManual(depth, properties = {}) { return { type: 'scroll', timestamp: Date.now(), data: { depth, manual: true, ...properties } }; } calculateScrollDepth() { const scrollTop = window.pageYOffset || document.documentElement.scrollTop; const docHeight = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight); const windowHeight = window.innerHeight; const scrollableHeight = docHeight - windowHeight; if (scrollableHeight <= 0) return 100; return Math.min(100, Math.round((scrollTop / scrollableHeight) * 100)); } createScrollActivity(depth, checkpoint) { return { type: 'scroll', timestamp: Date.now(), data: { depth, checkpoint, maxDepth: this.maxScrollDepth, url: window.location.href, title: document.title } }; } throttle() { this.isThrottled = true; setTimeout(() => { this.isThrottled = false; }, 100); } getMaxScrollDepth() { return this.maxScrollDepth; } getScrollCheckpoints() { return Array.from(this.scrollCheckpoints).sort((a, b) => a - b); } } class FormTracker { constructor(config) { this.formData = new Map(); this.config = config; } handleSubmit(event) { const form = event.target; if (!form) return null; return this.trackSubmission(DOMUtils.getFormId(form)); } handleChange(event) { if (!this.config.formTracking?.trackFieldChanges) return null; const target = event.target; if (!target || !target.form) return null; return this.trackFieldChange(target, 'change'); } handleInput(event) { if (!this.config.formTracking?.trackFieldChanges) return null; const target = event.target; if (!target || !target.form) return null; return this.trackFieldChange(target, 'input'); } trackSubmission(formId, properties = {}) { const form = document.getElementById(formId); const formData = form ? this.extractFormData(form) : {}; return { type: 'form_submit', timestamp: Date.now(), data: { formId, formData: this.config.formTracking?.anonymizeFormData ? this.anonymizeFormData(formData) : formData, url: window.location.href, ...properties } }; } trackFieldChange(field, eventType) { const formId = DOMUtils.getFormId(field.form); const fieldName = field.name || field.id || DOMUtils.getSelector(field); return { type: 'form_field_change', timestamp: Date.now(), data: { formId, fieldName, fieldType: field.type, eventType, hasValue: !!field.value, valueLength: field.value?.length || 0 } }; } extractFormData(form) { const data = {}; const formData = new FormData(form); //@ts-ignore for (const [key, value] of formData) { data[key] = value; } return data; } anonymizeFormData(data) { const anonymized = {}; for (const [key, value] of Object.entries(data)) { if (this.isSensitiveField(key)) { anonymized[key] = '[REDACTED]'; } else { anonymized[key] = value; } } return anonymized; } isSensitiveField(fieldName) { const sensitivePatterns = [ /password/i, /credit.?card/i, /ssn/i, /social.?security/i, /email/i, /phone/i, /address/i ]; return sensitivePatterns.some(pattern => pattern.test(fieldName)); } } class SessionManager { constructor(config) { this.config = config; this.session = this.createSession(); } start() { this.startHeartbeat(); this.resetTimeout(); } stop() { this.stopHeartbeat(); this.stopTimeout(); this.session.endTime = Date.now(); this.session.duration = this.session.endTime - this.session.startTime; } reset() { this.stop(); this.session = this.createSession(); this.start(); } getSession() { this.updateSession(); return { ...this.session }; } getCurrentSessionId() { return this.session.id; } createSession() { return { id: this.generateSessionId(), startTime: Date.now(), lastActivity: Date.now(), duration: 0, pageViews: 0, clicks: 0, scrollDepth: 0, events: 0, isActive: true }; } updateSession() { const now = Date.now(); this.session.duration = now - this.session.startTime; this.session.lastActivity = now; this.session.events = (this.session.events || 0) + 1; } generateSessionId() { return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } startHeartbeat() { this.heartbeatTimer = setInterval(() => { this.updateSession(); }, 30000); // 30 seconds } stopHeartbeat() { if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = undefined; } } resetTimeout() { this.stopTimeout(); this.timeoutTimer = setTimeout(() => { this.session.isActive = false; this.stop(); }, this.config.sessionTimeout); } stopTimeout() { if (this.timeoutTimer) { clearTimeout(this.timeoutTimer); this.timeoutTimer = undefined; } } } class UserTracker { constructor(config = {}) { this.isTracking = false; this.eventListeners = []; this.activityBuffer = []; this.throttledMouseMove = EventUtils.throttle((event) => { const activity = { type: 'mousemove', timestamp: Date.now(), data: { x: event.clientX, y: event.clientY, sessionId: this.sessionManager.getCurrentSessionId() } }; this.addActivity(activity); }, 100); this.config = { enablePageViews: config.enablePageViews ?? true, enableClicks: config.enableClicks ?? true, enableScrolling: config.enableScrolling ?? true, enableForms: config.enableForms ?? true, enableSessions: config.enableSessions ?? true, enableMouseMovement: config.enableMouseMovement ?? false, enableKeyboardEvents: config.enableKeyboardEvents ?? false, enableVisibilityTracking: config.enableVisibilityTracking ?? true, sessionTimeout: config.sessionTimeout || 1800000, // 30 minutes scrollThreshold: config.scrollThreshold || 25, // 25% increments clickTracking: { trackAllClicks: config.clickTracking?.trackAllClicks ?? true, trackRageClicks: config.clickTracking?.trackRageClicks ?? true, trackDeadClicks: config.clickTracking?.trackDeadClicks ?? true, blacklistSelectors: config.clickTracking?.blacklistSelectors || [], whitelistSelectors: config.clickTracking?.whitelistSelectors || [], ...config.clickTracking }, formTracking: { trackSubmissions: config.formTracking?.trackSubmissions ?? true, trackFieldChanges: config.formTracking?.trackFieldChanges ?? false, trackValidationErrors: config.formTracking?.trackValidationErrors ?? true, anonymizeFormData: config.formTracking?.anonymizeFormData ?? true, ...config.formTracking }, batchSize: config.batchSize || 50, flushInterval: config.flushInterval || 10000, // 10 seconds debug: config.debug ?? false, respectPrivacy: config.respectPrivacy ?? true, ...config }; this.logger = new Loggers(this.config.debug); // Initialize tracking components this.sessionManager = new SessionManager(this.config); this.pageViewTracker = new PageViewTracker(this.config); this.clickTracker = new ClickTracker(this.config); this.scrollTracker = new ScrollTracker(this.config); this.formTracker = new FormTracker(this.config); // Set up event handlers this.setupEventHandlers(); this.logger.debug('UserTracker initialized', this.config); } start() { if (this.isTracking) { this.logger.warn('User tracking already started'); return; } if (!this.canTrack()) { this.logger.info('User tracking disabled due to privacy settings'); return; } try { this.isTracking = true; // Start session management if (this.config.enableSessions) { this.sessionManager.start(); } // Set up DOM event listeners this.setupEventListeners(); // Start automatic flushing this.startAutoFlush(); // Track initial page view if (this.config.enablePageViews) { this.trackInitialPageView(); } this.logger.info('User tracking started'); } catch (error) { this.logger.error('Failed to start user tracking:', error); this.isTracking = false; } } stop() { if (!this.isTracking) { return; } try { this.isTracking = false; // Remove all event listeners this.removeEventListeners(); // Stop auto-flush this.stopAutoFlush(); // Flush remaining activities this.flush(); // Stop session if (this.config.enableSessions) { this.sessionManager.stop(); } this.logger.info('User tracking stopped'); } catch (error) { this.logger.error('Error stopping user tracking:', error); } } // Manual tracking methods trackPageView(page, properties = {}) { if (!this.isTracking) return; const activity = this.pageViewTracker.track(page, properties); this.addActivity(activity); } trackClick(selector, properties = {}) { if (!this.isTracking) return; const activity = this.clickTracker.trackManual(selector, properties); this.addActivity(activity); } trackFormSubmit(formId, properties = {}) { if (!this.isTracking) return; const activity = this.formTracker.trackSubmission(formId, properties); this.addActivity(activity); } trackScroll(depth, properties = {}) { if (!this.isTracking) return; const activity = this.scrollTracker.trackManual(depth, properties); this.addActivity(activity); } trackCustomEvent(eventName, properties = {}) { if (!this.isTracking) return; const activity = { type: 'custom', timestamp: Date.now(), data: { eventName, ...properties, sessionId: this.sessionManager.getCurrentSessionId(), url: window.location.href, referrer: document.referrer } }; this.addActivity(activity); } // Session management getSession() { return this.sessionManager.getSession(); } resetSession() { this.sessionManager.reset(); } // Activity management addActivity(activity) { this.activityBuffer.push(activity); // Auto-flush if buffer is full if (this.activityBuffer.length >= this.config.batchSize) { this.flush(); } } flush() { const activities = [...this.activityBuffer]; this.activityBuffer = []; if (activities.length > 0) { this.emitActivities(activities); this.logger.debug(`Flushed ${activities.length} activities`); } return activities; } emitActivities(activities) { // Emit events for external consumption if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent('userActivities', { detail: { activities, session: this.getSession() } })); } } // Event listener setup setupEventHandlers() { // Page view tracking if (this.config.enablePageViews) { this.pageViewTracker.onPageView = (activity) => this.addActivity(activity); } // Click tracking if (this.config.enableClicks) { this.clickTracker.onClick = (activity) => this.addActivity(activity); } // Scroll tracking if (this.config.enableScrolling) { this.scrollTracker.onScroll = (activity) => this.addActivity(activity); } // Form tracking if (this.config.enableForms) { this.formTracker.onFormEvent = (activity) => this.addActivity(activity); } } setupEventListeners() { if (typeof window === 'undefined' || typeof document === 'undefined') { return; } // Click events if (this.config.enableClicks) { this.addEventListener(document, 'click', this.handleClick.bind(this)); } // Scroll events if (this.config.enableScrolling) { this.addEventListener(window, 'scroll', this.handleScroll.bind(this)); } // Form events if (this.config.enableForms) { this.addEventListener(document, 'submit', this.handleFormSubmit.bind(this)); if (this.config.formTracking.trackFieldChanges) { this.addEventListener(document, 'change', this.handleFormChange.bind(this)); this.addEventListener(document, 'input', this.handleFormInput.bind(this)); } } // Mouse movement (if enabled) if (this.config.enableMouseMovement) { this.addEventListener(document, 'mousemove', this.handleMouseMove.bind(this)); } // Keyboard events (if enabled) if (this.config.enableKeyboardEvents) { this.addEventListener(document, 'keydown', this.handleKeyDown.bind(this)); } // Visibility tracking if (this.config.enableVisibilityTracking) { this.addEventListener(document, 'visibilitychange', this.handleVisibilityChange.bind(this)); } // Page navigation if (this.config.enablePageViews) { this.addEventListener(window, 'popstate', this.handlePopState.bind(this)); this.interceptHistoryAPI(); } // Page unload this.addEventListener(window, 'beforeunload', this.handleBeforeUnload.bind(this)); } addEventListener(element, event, handler) { const wrappedHandler = (event) => handler(event); element.addEventListener(event, wrappedHandler); this.eventListeners.push({ element, event, handler: wrappedHandler }); } removeEventListeners() { this.eventListeners.forEach(({ element, event, handler }) => { element.removeEventListener(event, handler); }); this.eventListeners = []; } // Event handlers handleClick(event) { if (!this.isTracking) return; const activity = this.clickTracker.handleClick(event); if (activity) { this.addActivity(activity); } } handleScroll() { if (!this.isTracking) return; const activity = this.scrollTracker.handleScroll(); if (activity) { this.addActivity(activity); } } handleFormSubmit(event) { if (!this.isTracking) return; const activity = this.formTracker.handleSubmit(event); if (activity) { this.addActivity(activity); } } handleFormChange(event) { if (!this.isTracking) return; const activity = this.formTracker.handleChange(event); if (activity) { this.addActivity(activity); } } handleFormInput(event) { if (!this.isTracking) return; const activity = this.formTracker.handleInput(event); if (activity) { this.addActivity(activity); } } handleMouseMove(event) { if (!this.isTracking) return; // Throttle mouse move events this.throttledMouseMove(event); } handleKeyDown(event) { if (!this.isTracking) return; // Only track non-sensitive keys if (this.isSensitiveKey(event.key)) return; const activity = { type: 'keydown', timestamp: Date.now(), data: { key: event.key, code: event.code, ctrlKey: event.ctrlKey, shiftKey: event.shiftKey, altKey: event.altKey, sessionId: this.sessionManager.getCurrentSessionId() } }; this.addActivity(activity); } handleVisibilityChange() { if (!this.isTracking) return; const activity = { type: 'visibility', timestamp: Date.now(), data: { hidden: document.hidden, visibilityState: document.visibilityState, sessionId: this.sessionManager.getCurrentSessionId() } }; this.addActivity(activity); } handlePopState() { if (!this.isTracking) return; setTimeout(() => { this.trackPageView(window.location.pathname); }, 0); } handleBeforeUnload() { // Flush remaining activities before page unload this.flush(); } // History API interception for SPA navigation interceptHistoryAPI() { const originalPushState = history.pushState; const originalReplaceState = history.replaceState; history.pushState = (...args) => { originalPushState.apply(history, args); setTimeout(() => { this.trackPageView(window.location.pathname); }, 0); }; history.replaceState = (...args) => { originalReplaceState.apply(history, args); setTimeout(() => { this.trackPageView(window.location.pathname); }, 0); }; } trackInitialPageView() { if (typeof window !== 'undefined') { this.trackPageView(window.location.pathname, { title: document.title, referrer: document.referrer, isInitial: true }); } } // Auto-flush management startAutoFlush() { this.flushTimer = setInterval(() => { this.flush(); }, this.config.flushInterval); } stopAutoFlush() { if (this.flushTimer) { clearInterval(this.flushTimer); this.flushTimer = undefined; } } // Utility methods canTrack() { if (!this.config.respectPrivacy) return true; // Check Do Not Track if (typeof navigator !== 'undefined') { const dnt = navigator.doNotTrack; if (dnt === '1' || dnt === 'yes') { return false; } } // Check consent (if implemented) if (typeof localStorage !== 'undefined') { const consent = localStorage.getItem('tracking_consent'); if (consent === 'false') { return false; } } return true; } isSensitiveKey(key) { const sensitiveKeys = [ 'Tab', 'CapsLock', 'Shift', 'Control', 'Alt', 'Meta', 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12' ]; return sensitiveKeys.includes(key); } // Statistics and analytics getTrackingStats() { const session = this.getSession(); return { isTracking: this.isTracking, sessionInfo: session, activitiesBuffered: this.activityBuffer.length, totalActivities: session.events || 0, trackingStartTime: session.startTime, lastActivity: session.lastActivity }; } // Configuration updates updateConfig(config) { Object.assign(this.config, config); this.logger.debug('Configuration updated', config); } getConfig() { return { ...this.config }; } // Event listener for external integration onActivity(callback) { if (typeof window !== 'undefined') { window.addEventListener('userActivities', (event) => { callback(event.detail.activities, event.detail.session); }); } } // Cleanup destroy() { this.stop(); this.logger.debug('UserTracker destroyed'); } } export { BufferedOutput, ClickTracker, ConsoleOutput, DOMUtils, EventUtils, FileOutput, FormTracker, Loggers, PageViewTracker, ScrollTracker, SessionManager, UserTracker, UserTracker as default }; //# sourceMappingURL=index.esm.js.map