@wirequery/wirequery-js-core
Version:
WireQuery JS SDK Core
388 lines (387 loc) • 15.4 kB
JavaScript
;
// 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;