UNPKG

@wirequery/wirequery-js-core

Version:
388 lines (387 loc) 15.4 kB
"use strict"; // Copyright 2023 Wouter Nederhof // // Use of this source code is governed by the MIT // license that can be found in the `licenses` folder. // // SPDX-License-Identifier: MIT Object.defineProperty(exports, "__esModule", { value: true }); exports.getRecordNetworkPlugin = exports.NETWORK_PLUGIN_NAME = void 0; function findLast(array, predicate) { const length = array.length; for (let i = length - 1; i >= 0; i -= 1) { if (predicate(array[i])) { return array[i]; } } } function patch(source, name, replacement) { try { if (!(name in source)) { return () => { // }; } const original = source[name]; const wrapped = replacement(original); // Make sure it's a function first, as we need to attach an empty prototype for `defineProperties` to work // otherwise it'll throw "TypeError: Object.defineProperties called on non-object" if (typeof wrapped === 'function') { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment wrapped.prototype = wrapped.prototype || {}; Object.defineProperties(wrapped, { __rrweb_original__: { enumerable: false, value: original, }, }); } source[name] = wrapped; return () => { source[name] = original; }; } catch { return () => { // }; // This can throw if multiple fill happens on a global object like XMLHttpRequest // Fixes https://github.com/getsentry/sentry-javascript/issues/2043 } } const defaultNetworkOptions = { initiatorTypes: [ 'audio', 'beacon', 'body', 'css', 'early-hint', 'embed', 'fetch', 'frame', 'iframe', 'icon', 'image', 'img', 'input', 'link', 'navigation', 'object', 'ping', 'script', 'track', 'video', 'xmlhttprequest', ], ignoreRequestFn: () => false, recordHeaders: false, recordBody: false, recordInitialRequests: false, }; const isNavigationTiming = (entry) => entry.entryType === 'navigation'; const isResourceTiming = (entry) => entry.entryType === 'resource'; function initPerformanceObserver(cb, win, options) { if (options.recordInitialRequests) { const initialPerformanceEntries = win.performance .getEntries() .filter((entry) => isNavigationTiming(entry) || (isResourceTiming(entry) && options.initiatorTypes.includes(entry.initiatorType))); cb({ requests: initialPerformanceEntries.map((entry) => ({ url: entry.name, initiatorType: entry.initiatorType, status: 'responseStatus' in entry ? entry.responseStatus : undefined, startTime: Math.round(entry.startTime), endTime: Math.round(entry.responseEnd), })), isInitial: true, }); } const observer = new win.PerformanceObserver((entries) => { const performanceEntries = entries .getEntries() .filter((entry) => isNavigationTiming(entry) || (isResourceTiming(entry) && options.initiatorTypes.includes(entry.initiatorType) && entry.initiatorType !== 'xmlhttprequest' && entry.initiatorType !== 'fetch')); cb({ requests: performanceEntries.map((entry) => ({ url: entry.name, initiatorType: entry.initiatorType, status: 'responseStatus' in entry ? entry.responseStatus : undefined, startTime: Math.round(entry.startTime), endTime: Math.round(entry.responseEnd), })), }); }); observer.observe({ entryTypes: ['navigation', 'resource'] }); return () => { observer.disconnect(); }; } function shouldRecordHeaders(type, recordHeaders) { return (!!recordHeaders && (typeof recordHeaders === 'boolean' || recordHeaders[type])); } function shouldRecordBody(type, recordBody, headers) { function matchesContentType(contentTypes) { const contentTypeHeader = Object.keys(headers).find((key) => key.toLowerCase() === 'content-type'); const contentType = contentTypeHeader && headers[contentTypeHeader]; return contentTypes.some((ct) => contentType?.includes(ct)); } if (!recordBody) return false; if (typeof recordBody === 'boolean') return true; if (Array.isArray(recordBody)) return matchesContentType(recordBody); const recordBodyType = recordBody[type]; if (typeof recordBodyType === 'boolean') return recordBodyType; return matchesContentType(recordBodyType); } async function getRequestPerformanceEntry(win, initiatorType, url, after, before, attempt = 0) { if (attempt > 10) { throw new Error('Cannot find performance entry'); } const urlPerformanceEntries = win.performance.getEntriesByName(url); const performanceEntry = findLast(urlPerformanceEntries, (entry) => isResourceTiming(entry) && entry.initiatorType === initiatorType && (!after || entry.startTime >= after) && (!before || entry.startTime <= before)); if (!performanceEntry) { await new Promise((resolve) => setTimeout(resolve, 50 * attempt)); return getRequestPerformanceEntry(win, initiatorType, url, after, before, attempt + 1); } return performanceEntry; } function initXhrObserver(cb, win, options) { if (!options.initiatorTypes.includes('xmlhttprequest')) { return () => { // }; } const recordRequestHeaders = shouldRecordHeaders('request', options.recordHeaders); const recordResponseHeaders = shouldRecordHeaders('response', options.recordHeaders); const restorePatch = patch(win.XMLHttpRequest.prototype, 'open', (originalOpen) => { // @ts-ignore return function (method, url, async = true, username, password) { const xhr = this; const req = new Request(url); if (window.recordingCorrelationId) { req.headers.set('wirequery-request-correlation-id', crypto.randomUUID()); req.headers.set('wirequery-recording-correlation-id', window.recordingCorrelationId); } const networkRequest = {}; let after; let before; const requestHeaders = {}; const originalSetRequestHeader = xhr.setRequestHeader.bind(xhr); xhr.setRequestHeader = (header, value) => { requestHeaders[header] = value; return originalSetRequestHeader(header, value); }; if (recordRequestHeaders) { networkRequest.requestHeaders = requestHeaders; } else { if (requestHeaders['wirequery-request-correlation-id']) { networkRequest.requestHeaders = { 'wirequery-request-correlation-id': requestHeaders['wirequery-request-correlation-id'] }; } } const originalSend = xhr.send.bind(xhr); xhr.send = (body) => { if (shouldRecordBody('request', options.recordBody, requestHeaders)) { if (body === undefined || body === null) { networkRequest.requestBody = null; } else { networkRequest.requestBody = body; } } after = win.performance.now(); return originalSend(body); }; xhr.addEventListener('readystatechange', () => { if (xhr.readyState !== xhr.DONE) { return; } before = win.performance.now(); const responseHeaders = {}; const rawHeaders = xhr.getAllResponseHeaders(); const headers = rawHeaders.trim().split(/[\r\n]+/); headers.forEach((line) => { const parts = line.split(': '); const header = parts.shift(); const value = parts.join(': '); if (header) { responseHeaders[header] = value; } }); if (recordResponseHeaders) { networkRequest.responseHeaders = responseHeaders; } if (shouldRecordBody('response', options.recordBody, responseHeaders)) { if (xhr.response === undefined || xhr.response === null) { networkRequest.responseBody = null; } else { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment networkRequest.responseBody = xhr.response; } } getRequestPerformanceEntry(win, 'xmlhttprequest', req.url, after, before) .then((entry) => { const request = { url: entry.name, method: req.method, initiatorType: entry.initiatorType, status: xhr.status, startTime: Math.round(entry.startTime), endTime: Math.round(entry.responseEnd), requestHeaders: networkRequest.requestHeaders, requestBody: networkRequest.requestBody, responseHeaders: networkRequest.responseHeaders, responseBody: networkRequest.responseBody, }; cb({ requests: [request] }); }) .catch(() => { // }); }); originalOpen.call(xhr, method, url, async, username, password); }; }); return () => { restorePatch(); }; } function initFetchObserver(cb, win, options) { if (!options.initiatorTypes.includes('fetch')) { return () => { // }; } const recordRequestHeaders = shouldRecordHeaders('request', options.recordHeaders); const recordResponseHeaders = shouldRecordHeaders('response', options.recordHeaders); const restorePatch = patch(win, 'fetch', (originalFetch) => { return async function (url, init) { const req = new Request(url, init); if (window.recordingCorrelationId) { req.headers.set('wirequery-request-correlation-id', crypto.randomUUID()); req.headers.set('wirequery-recording-correlation-id', window.recordingCorrelationId); } let res; const networkRequest = {}; let after; let before; try { const requestHeaders = {}; req.headers.forEach((value, header) => { requestHeaders[header] = value; }); if (recordRequestHeaders) { networkRequest.requestHeaders = requestHeaders; } else { if (requestHeaders['wirequery-request-correlation-id']) { networkRequest.requestHeaders = { 'wirequery-request-correlation-id': requestHeaders['wirequery-request-correlation-id'] }; } } if (shouldRecordBody('request', options.recordBody, requestHeaders)) { if (req.body === undefined || req.body === null) { networkRequest.requestBody = null; } else { networkRequest.requestBody = req.body; } } after = win.performance.now(); res = await originalFetch(req); before = win.performance.now(); const responseHeaders = {}; res?.headers.forEach((value, header) => { responseHeaders[header] = value; }); if (recordResponseHeaders) { networkRequest.responseHeaders = responseHeaders; } if (shouldRecordBody('response', options.recordBody, responseHeaders)) { let body; try { body = await res?.clone().text(); } catch { // } if (res?.body === undefined || res.body === null) { networkRequest.responseBody = null; } else { networkRequest.responseBody = body; } } return res; } finally { getRequestPerformanceEntry(win, 'fetch', req.url, after, before) .then((entry) => { const request = { url: entry.name, method: req.method, initiatorType: entry.initiatorType, status: res?.status, startTime: Math.round(entry.startTime), endTime: Math.round(entry.responseEnd), requestHeaders: networkRequest.requestHeaders, requestBody: networkRequest.requestBody, responseHeaders: networkRequest.responseHeaders, responseBody: networkRequest.responseBody, }; cb({ requests: [request] }); }) .catch(() => { // }); } }; }); return () => { restorePatch(); }; } function initNetworkObserver(callback, win, // top window or in an iframe options) { if (!('performance' in win)) { return () => { // }; } const networkOptions = (options ? Object.assign({}, defaultNetworkOptions, options) : defaultNetworkOptions); const cb = (data) => { const requests = data.requests.filter((request) => !networkOptions.ignoreRequestFn(request)); if (requests.length > 0 || data.isInitial) { callback({ ...data, requests }); } }; const performanceObserver = initPerformanceObserver(cb, win, networkOptions); const xhrObserver = initXhrObserver(cb, win, networkOptions); const fetchObserver = initFetchObserver(cb, win, networkOptions); return () => { performanceObserver(); xhrObserver(); fetchObserver(); }; } exports.NETWORK_PLUGIN_NAME = 'rrweb/network@1'; const getRecordNetworkPlugin = (options) => ({ name: exports.NETWORK_PLUGIN_NAME, observer: initNetworkObserver, options: options, }); exports.getRecordNetworkPlugin = getRecordNetworkPlugin;