diff --git a/.changeset/config.json b/.changeset/config.json index 89ad04bbc7..f62e07928b 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -22,7 +22,9 @@ "@rrweb/rrweb-plugin-sequential-id-record", "@rrweb/rrweb-plugin-sequential-id-replay", "@rrweb/rrweb-plugin-canvas-webrtc-record", - "@rrweb/rrweb-plugin-canvas-webrtc-replay" + "@rrweb/rrweb-plugin-canvas-webrtc-replay", + "@rrweb/rrweb-plugin-network-record", + "@rrweb/rrweb-plugin-network-replay" ] ], "linked": [], diff --git a/.changeset/six-lions-guess.md b/.changeset/six-lions-guess.md new file mode 100644 index 0000000000..855bbf627d --- /dev/null +++ b/.changeset/six-lions-guess.md @@ -0,0 +1,4 @@ +--- +--- + +network-plugin diff --git a/docs/recipes/network.md b/docs/recipes/network.md new file mode 100644 index 0000000000..e21305e9a7 --- /dev/null +++ b/docs/recipes/network.md @@ -0,0 +1,108 @@ +# network recorder and replayer + +Starting from v2.0.0, we add the plugin to record network output. +This feature aims to provide developers with more information about the bug scene. There are some options for recording and replaying network output. + +### Enable recording network + +You can enable using default option like this: + +```js +import rrweb from 'rrweb'; +import { getRecordNetworkPlugin } from '@rrweb/rrweb-plugin-network-record'; + +rrweb.record({ + emit: function emit(event) { + events.push(event); + }, + // to use default record option + plugins: [getRecordNetworkPlugin()], +}); +``` + +You can also customize the behavior of logger like this: + +```js +import rrweb from 'rrweb'; +import { getRecordNetworkPlugin } from '@rrweb/rrweb-plugin-network-record'; + +rrweb.record({ + emit: function emit(event) { + fetch('https://api.my-server.com/events', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + events: [event], + }), + }); + }, + // customized record options + plugins: [ + getRecordNetworkPlugin({ + initiatorTypes: ['fetch', 'xmlhttprequest'], + // mask/block recording event for request + transformRequestFn: (request) => { + // request.name is url + if (request.name.contains('rrweb-collector-api.com')) return; // skip request + delete request.requestHeaders['Authorization']; // remove sensetive data + request.responseBody = maskTextFn(request.responseBody); + return request; + }, + recordHeaders: true, + recordBody: true, + recordInitialRequests: false, + }), + ], +}); +``` + +**alert**: If you are uploading events to a server, you should always use `transformRequestFn` to mask/block recording events for these requests or else you will cause a nasty loop. + +All options are described below: + +| key | default | description | +| --------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| initiatorTypes | `['fetch','xmlhttprequest','img',...]` | Default value contains names of all [initiator types](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/initiatorType). You can override it by setting the types you need. | +| transformRequestFn | `(request) => request` | Transform recording event for request to block (skip) or mask/transofrm request (e.g. to hide sensetive data) | +| recordHeaders | `false` | Record the request & response headers for `fetch` and `xmlhttprequest` requests | +| recordBody | `false` | Record the request & response bodies for `fetch` and `xmlhttprequest` requests | +| recordInitialRequests | `false` | Record an event for all requests prior to rrweb.record() being called | + +## replay network + +It is up to you to decide how to best replay your network events using the `onNetworkData` callback. + +```js +import rrweb from 'rrweb'; +import { getReplayNetworkPlugin } from '@rrweb/rrweb-plugin-network-replay'; + +const replayer = new rrweb.Replayer(events, { + plugins: [ + getReplayNetworkPlugin({ + onNetworkData: ({ requests }) => { + for (const request of requests) { + const name = request.name; // url + const method = request.method; + const status = request.status; + console.log(`${method} ${name} ${status}`); + } + }, + }), + ], +}); +replayer.play(); +``` + +Description of replay option is as follows: + +| key | default | description | +| ------------- | ----------- | ------------------------------------------------------------------------------------------ | +| onNetworkData | `undefined` | You could use this interface to replay the network requests in a simulated browser console | + +## technical implementation + +This implementation records [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) and [`XMLHttpRequest`](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) by patching their object & methods. We record document navigation using [`PerformanceNavigationTiming`](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceNavigationTiming) and we use [`PerformanceResourceTiming`](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming) for recording everything else (script, img, link etc.) via [`PerformanceObserver`](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceObserver) API. + +For more information please see [[network-plugin] Feat: Capture network events #1105](https://github.com/rrweb-io/rrweb/pull/1105) PR. diff --git a/docs/recipes/plugin.md b/docs/recipes/plugin.md index d322bc1eaf..8b48bd1e18 100644 --- a/docs/recipes/plugin.md +++ b/docs/recipes/plugin.md @@ -10,6 +10,8 @@ The plugin API is designed to extend the function of rrweb without bump the size - [@rrweb/rrweb-plugin-sequential-id-replay](packages/plugins/rrweb-plugin-sequential-id-replay): A plugin for replaying sequential IDs. - [@rrweb/rrweb-plugin-canvas-webrtc-record](packages/plugins/rrweb-plugin-canvas-webrtc-record): A plugin for stream `` via WebRTC. - [@rrweb/rrweb-plugin-canvas-webrtc-replay](packages/plugins/rrweb-plugin-canvas-webrtc-replay): A plugin for playing streamed `` via WebRTC. +- [@rrweb/rrweb-plugin-network-record](packages/plugins/rrweb-plugin-network-record): A plugin for recording network requests (xhr/fetch). +- [@rrweb/rrweb-plugin-network-replay](packages/plugins/rrweb-plugin-network-replay): A plugin for replaying network requests (xhr/fetch). ## Interface diff --git a/docs/recipes/plugin.zh_CN.md b/docs/recipes/plugin.zh_CN.md index 806e4e228c..7fd43c19e4 100644 --- a/docs/recipes/plugin.zh_CN.md +++ b/docs/recipes/plugin.zh_CN.md @@ -10,6 +10,8 @@ - [@rrweb/rrweb-plugin-sequential-id-replay](packages/plugins/rrweb-plugin-sequential-id-replay):一个用于回放顺序 ID 的插件。 - [@rrweb/rrweb-plugin-canvas-webrtc-record](packages/plugins/rrweb-plugin-canvas-webrtc-record):一个用于通过 WebRTC 流式传输 `` 的插件。 - [@rrweb/rrweb-plugin-canvas-webrtc-replay](packages/plugins/rrweb-plugin-canvas-webrtc-replay):一个用于通过 WebRTC 播放流式 `` 的插件。 +- [@rrweb/rrweb-plugin-network-record](packages/plugins/rrweb-plugin-network-record): 一个用于记录网络请求的插件 (xhr/fetch)。 +- [@rrweb/rrweb-plugin-network-replay](packages/plugins/rrweb-plugin-network-replay): 一个用于回放网络请求的插件 (xhr/fetch)。 ## 接口 diff --git a/guide.md b/guide.md index 764e359fb4..5472414f97 100644 --- a/guide.md +++ b/guide.md @@ -56,6 +56,8 @@ Besides the `rrweb` and `@rrweb/record` packages, rrweb also provides other pack - [@rrweb/rrweb-plugin-sequential-id-replay](packages/plugins/rrweb-plugin-sequential-id-replay): A plugin for replaying sequential IDs. - [@rrweb/rrweb-plugin-canvas-webrtc-record](packages/plugins/rrweb-plugin-canvas-webrtc-record): A plugin for stream `` via WebRTC. - [@rrweb/rrweb-plugin-canvas-webrtc-replay](packages/plugins/rrweb-plugin-canvas-webrtc-replay): A plugin for playing streamed `` via WebRTC. +- [@rrweb/rrweb-plugin-network-record](packages/plugins/rrweb-plugin-network-record): A plugin for recording network requests (xhr/fetch). +- [@rrweb/rrweb-plugin-network-replay](packages/plugins/rrweb-plugin-network-replay): A plugin for replaying network requests (xhr/fetch). ### NPM diff --git a/guide.zh_CN.md b/guide.zh_CN.md index 4078cb2b6a..f3792cfe84 100644 --- a/guide.zh_CN.md +++ b/guide.zh_CN.md @@ -53,6 +53,8 @@ rrweb 代码分为录制和回放两部分,大多数时候用户在被录制 - [@rrweb/rrweb-plugin-sequential-id-replay](packages/plugins/rrweb-plugin-sequential-id-replay):一个用于回放顺序 ID 的插件。 - [@rrweb/rrweb-plugin-canvas-webrtc-record](packages/plugins/rrweb-plugin-canvas-webrtc-record):一个用于通过 WebRTC 流式传输 `` 的插件。 - [@rrweb/rrweb-plugin-canvas-webrtc-replay](packages/plugins/rrweb-plugin-canvas-webrtc-replay):一个用于通过 WebRTC 播放流式 `` 的插件。 +- [@rrweb/rrweb-plugin-network-record](packages/plugins/rrweb-plugin-network-record): 一个用于记录网络请求的插件 (xhr/fetch)。 +- [@rrweb/rrweb-plugin-network-replay](packages/plugins/rrweb-plugin-network-replay): 一个用于回放网络请求的插件 (xhr/fetch)。 ### 通过 npm 引入 diff --git a/packages/plugins/rrweb-plugin-network-record/CHANGELOG.md b/packages/plugins/rrweb-plugin-network-record/CHANGELOG.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/plugins/rrweb-plugin-network-record/README.md b/packages/plugins/rrweb-plugin-network-record/README.md new file mode 100644 index 0000000000..0407ba3340 --- /dev/null +++ b/packages/plugins/rrweb-plugin-network-record/README.md @@ -0,0 +1,178 @@ +# @rrweb/rrweb-plugin-network-record + +Please refer to the [network recipe](../../../docs/recipes/network.md) on how to use this plugin. +See the [guide](../../../guide.md) for more info on rrweb. + +## Sponsors + +[Become a sponsor](https://opencollective.com/rrweb#sponsor) and get your logo on our README on Github with a link to your site. + +### Gold Sponsors 🥇 + +
+ +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor + +
+ +### Silver Sponsors 🥈 + +
+ +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor + +
+ +### Bronze Sponsors 🥉 + +
+ +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor + +
+ +### Backers + + + +## Core Team Members + + + + + + + + +
+ + +
Yuyz0112 +

+
+
+ + +
Yun Feng +

+
+
+ + +
eoghanmurray +

+
+
+ + +
Juice10 +
open for rrweb consulting +
+
+ +## Who's using rrweb? + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + Smart screen recording for SaaS + +
+ + The first ever UX automation tool + + + + Remote Access & Co-Browsing + + + + The open source, fullstack Monitoring Platform. + + + + Comprehensive data analytics platform that empowers businesses to gain valuable insights and make data-driven decisions. + +
+ + Intercept, Modify, Record & Replay HTTP Requests. + + + + In-app bug reporting & customer feedback platform. + + + + Self-hosted website analytics with heatmaps and session recordings. + + + + Interactive product demos for small marketing teams + +
diff --git a/packages/plugins/rrweb-plugin-network-record/package.json b/packages/plugins/rrweb-plugin-network-record/package.json new file mode 100644 index 0000000000..7206d5099c --- /dev/null +++ b/packages/plugins/rrweb-plugin-network-record/package.json @@ -0,0 +1,55 @@ +{ + "name": "@rrweb/rrweb-plugin-network-record", + "version": "2.0.0-alpha.18", + "description": "", + "type": "module", + "main": "./dist/rrweb-plugin-network-record.umd.cjs", + "module": "./dist/rrweb-plugin-network-record.js", + "unpkg": "./dist/rrweb-plugin-network-record.umd.cjs", + "typings": "dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/rrweb-plugin-network-record.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/rrweb-plugin-network-record.umd.cjs" + } + } + }, + "files": [ + "dist", + "package.json" + ], + "scripts": { + "dev": "vite build --watch", + "build": "yarn turbo run prepublish", + "check-types": "tsc -noEmit", + "prepublish": "tsc -noEmit && vite build" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/rrweb-io/rrweb.git" + }, + "keywords": [ + "rrweb" + ], + "author": "yanzhen@smartx.com", + "license": "MIT", + "bugs": { + "url": "https://github.com/rrweb-io/rrweb/issues" + }, + "homepage": "https://github.com/rrweb-io/rrweb#readme", + "devDependencies": { + "rrweb": "^2.0.0-alpha.18", + "typescript": "^5.4.5", + "vite": "^5.3.1", + "vite-plugin-dts": "^3.9.1" + }, + "peerDependencies": { + "rrweb": "^2.0.0-alpha.18", + "@rrweb/utils": "^2.0.0-alpha.18" + } +} diff --git a/packages/plugins/rrweb-plugin-network-record/src/index.ts b/packages/plugins/rrweb-plugin-network-record/src/index.ts new file mode 100644 index 0000000000..3f05cf7ef7 --- /dev/null +++ b/packages/plugins/rrweb-plugin-network-record/src/index.ts @@ -0,0 +1,568 @@ +import type { listenerHandler, RecordPlugin, IWindow } from '@rrweb/types'; +import { patch } from '@rrweb/utils'; + +export type InitiatorType = + | 'audio' + | 'beacon' + | 'body' + | 'css' + | 'early-hint' + | 'embed' + | 'fetch' + | 'frame' + | 'iframe' + | 'icon' + | 'image' + | 'img' + | 'input' + | 'link' + | 'navigation' + | 'object' + | 'ping' + | 'script' + | 'track' + | 'video' + | 'xmlhttprequest'; + +type NetworkRecordOptions = { + initiatorTypes?: InitiatorType[]; + transformRequestFn?: (request: NetworkRequest) => NetworkRequest | undefined; + recordHeaders?: boolean | { request: boolean; response: boolean }; + recordBody?: + | boolean + | string[] + | { request: boolean | string[]; response: boolean | string[] }; + recordInitialRequests?: boolean; +}; + +const defaultNetworkOptions: NetworkRecordOptions = { + initiatorTypes: [ + 'audio', + 'beacon', + 'body', + 'css', + 'early-hint', + 'embed', + 'fetch', + 'frame', + 'iframe', + 'icon', + 'image', + 'img', + 'input', + 'link', + 'navigation', + 'object', + 'ping', + 'script', + 'track', + 'video', + 'xmlhttprequest', + ], + transformRequestFn: (request) => request, + recordHeaders: false, + recordBody: false, + recordInitialRequests: false, +}; + +type Headers = Record; +type Body = + | string + | Document + | Blob + | ArrayBufferView + | ArrayBuffer + | FormData + | URLSearchParams + | ReadableStream + | null; + +type NetworkRequest = Omit< + PerformanceEntry, + 'toJSON' | 'startTime' | 'endTime' | 'duration' | 'entryType' +> & { + method?: string; + initiatorType?: InitiatorType; + status?: number; + startTime?: number; + endTime?: number; + duration?: number; + entryType?: string; + requestHeaders?: Headers; + requestBody?: Body; + responseHeaders?: Headers; + responseBody?: Body; + // was this captured before fetch/xhr could have been wrapped + isInitial?: boolean; +}; + +export type NetworkData = { + requests: NetworkRequest[]; + isInitial?: boolean; +}; + +type networkCallback = (data: NetworkData) => void; + +const isNavigationTiming = ( + entry: PerformanceEntry, +): entry is PerformanceNavigationTiming => entry.entryType === 'navigation'; +const isResourceTiming = ( + entry: PerformanceEntry, +): entry is PerformanceResourceTiming => entry.entryType === 'resource'; + +type ObservedPerformanceEntry = ( + | PerformanceNavigationTiming + | PerformanceResourceTiming +) & { + responseStatus?: number; +}; + +function initPerformanceObserver( + cb: networkCallback, + win: IWindow, + options: Required, +) { + if (options.recordInitialRequests) { + const initialPerformanceEntries = win.performance + .getEntries() + .filter( + (entry): entry is ObservedPerformanceEntry => + isNavigationTiming(entry) || + (isResourceTiming(entry) && + options.initiatorTypes.includes( + entry.initiatorType as InitiatorType, + )), + ); + cb({ + requests: initialPerformanceEntries.map((entry) => ({ + initiatorType: entry.initiatorType as InitiatorType, + duration: entry.duration, + entryType: entry.entryType, + name: entry.name, + 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): entry is ObservedPerformanceEntry => + isNavigationTiming(entry) || + (isResourceTiming(entry) && + options.initiatorTypes.includes( + entry.initiatorType as InitiatorType, + ) && + entry.initiatorType !== 'xmlhttprequest' && + entry.initiatorType !== 'fetch'), + ); + cb({ + requests: performanceEntries.map((entry) => ({ + initiatorType: entry.initiatorType as InitiatorType, + status: 'responseStatus' in entry ? entry.responseStatus : undefined, + startTime: Math.round(entry.startTime), + endTime: Math.round(entry.responseEnd), + duration: entry.duration, + entryType: entry.entryType, + name: entry.name, + })), + }); + }); + observer.observe({ entryTypes: ['navigation', 'resource'] }); + return () => { + observer.disconnect(); + }; +} + +function shouldRecordHeaders( + type: 'request' | 'response', + recordHeaders: NetworkRecordOptions['recordHeaders'], +) { + return ( + !!recordHeaders && + (typeof recordHeaders === 'boolean' || recordHeaders[type]) + ); +} + +function shouldRecordBody( + type: 'request' | 'response', + recordBody: NetworkRecordOptions['recordBody'], + headers: Headers, +) { + function matchesContentType(contentTypes: string[]) { + 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: IWindow, + initiatorType: string, + url: string, + after?: number, + before?: number, + attempt = 0, +): Promise { + if (attempt > 10) { + return null; + } + const urlPerformanceEntries = win.performance.getEntriesByName( + url, + ) as PerformanceResourceTiming[]; + 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: networkCallback, + win: IWindow, + options: Required, +): listenerHandler { + 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', + // @ts-expect-error // fix types + (originalOpen: typeof XMLHttpRequest.prototype.open) => { + return function ( + this: XMLHttpRequest, + method: string, + url: string | URL, + async = true, + username?: string | null, + password?: string | null, + ) { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const xhr = this; + const req = new Request(url); + const networkRequest: Partial = {}; + let after: number | undefined; + let before: number | undefined; + const requestHeaders: Headers = {}; + const originalSetRequestHeader = xhr.setRequestHeader.bind(xhr); + xhr.setRequestHeader = (header: string, value: string) => { + requestHeaders[header] = value; + return originalSetRequestHeader(header, value); + }; + if (recordRequestHeaders) { + networkRequest.requestHeaders = requestHeaders; + } + 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: Headers = {}; + 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) => { + if (!entry) { + // https://github.com/rrweb-io/rrweb/pull/1105#issuecomment-1953808336 + const requests = prepareRequestWithoutPerformance( + req, + networkRequest, + ); + cb({ requests }); + return; + } + + const requests = prepareRequest( + entry, + req.method, + xhr.status, + networkRequest, + ); + cb({ requests }); + }) + .catch(() => { + // + }); + }); + originalOpen.call(xhr, method, url, async, username, password); + }; + }, + ); + return () => { + restorePatch(); + }; +} + +function initFetchObserver( + cb: networkCallback, + win: IWindow, + options: Required, +): listenerHandler { + if (!options.initiatorTypes.includes('fetch')) { + return () => { + // + }; + } + const recordRequestHeaders = shouldRecordHeaders( + 'request', + options.recordHeaders, + ); + const recordResponseHeaders = shouldRecordHeaders( + 'response', + options.recordHeaders, + ); + // @ts-expect-error // fix types + const restorePatch = patch(win, 'fetch', (originalFetch: typeof fetch) => { + return async function ( + url: URL | RequestInfo, + init?: RequestInit | undefined, + ) { + const req = new Request(url, init); + let res: Response | undefined; + const networkRequest: Partial = {}; + let after: number | undefined; + let before: number | undefined; + try { + const requestHeaders: Headers = {}; + req.headers.forEach((value, header) => { + requestHeaders[header] = value; + }); + if (recordRequestHeaders) { + networkRequest.requestHeaders = requestHeaders; + } + 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: Headers = {}; + res.headers.forEach((value, header) => { + responseHeaders[header] = value; + }); + if (recordResponseHeaders) { + networkRequest.responseHeaders = responseHeaders; + } + if (shouldRecordBody('response', options.recordBody, responseHeaders)) { + let body: string | undefined; + 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) => { + if (!entry) { + // https://github.com/rrweb-io/rrweb/pull/1105#issuecomment-1953808336 + const requests = prepareRequestWithoutPerformance( + req, + networkRequest, + ); + cb({ requests }); + return; + } + + const requests = prepareRequest( + entry, + req.method, + res?.status, + networkRequest, + ); + cb({ requests }); + }) + .catch(() => { + // + }); + } + }; + }); + return () => { + restorePatch(); + }; +} + +function initNetworkObserver( + callback: networkCallback, + win: IWindow, // top window or in an iframe + options: NetworkRecordOptions, +): listenerHandler { + if (!('performance' in win)) { + return () => { + // + }; + } + const networkOptions = ( + options + ? Object.assign({}, defaultNetworkOptions, options) + : defaultNetworkOptions + ) as Required; + + const cb: networkCallback = (data) => { + const requests = data.requests + .map((request) => networkOptions.transformRequestFn(request)) + .filter(Boolean) as NetworkRequest[]; + + 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(); + }; +} + +function prepareRequest( + entry: PerformanceResourceTiming, + method: string | undefined, + status: number | undefined, + networkRequest: Partial, +): NetworkRequest[] { + const request: NetworkRequest = { + method, + initiatorType: entry.initiatorType as InitiatorType, + duration: entry.duration, + entryType: entry.entryType, + name: entry.name, + status, + startTime: Math.round(entry.startTime), + endTime: Math.round(entry.responseEnd), + requestHeaders: networkRequest.requestHeaders, + requestBody: networkRequest.requestBody, + responseHeaders: networkRequest.responseHeaders, + responseBody: networkRequest.responseBody, + }; + + return [request]; +} + +function prepareRequestWithoutPerformance( + req: Request, + networkRequest: Partial, +): NetworkRequest[] { + const request: NetworkRequest = { + name: req.url, + method: req.method, + requestHeaders: networkRequest.requestHeaders, + requestBody: networkRequest.requestBody, + responseHeaders: networkRequest.responseHeaders, + responseBody: networkRequest.responseBody, + }; + + return [request]; +} + +function findLast( + array: Array, + predicate: (value: T) => boolean, +): T | undefined { + const length = array.length; + for (let i = length - 1; i >= 0; i -= 1) { + if (predicate(array[i])) { + return array[i]; + } + } +} + +export const PLUGIN_NAME = 'rrweb/network@1'; + +export const getRecordNetworkPlugin: ( + options?: NetworkRecordOptions, +) => RecordPlugin = (options) => ({ + name: PLUGIN_NAME, + // @ts-expect-error // fix types + observer: initNetworkObserver, + options: options, +}); diff --git a/packages/plugins/rrweb-plugin-network-record/tsconfig.json b/packages/plugins/rrweb-plugin-network-record/tsconfig.json new file mode 100644 index 0000000000..01e3f2b7d8 --- /dev/null +++ b/packages/plugins/rrweb-plugin-network-record/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../tsconfig.base.json", + "include": ["src"], + "exclude": ["vite.config.ts", "test"], + "compilerOptions": { + "rootDir": "src", + "tsBuildInfoFile": "./tsconfig.tsbuildinfo" + }, + "references": [ + { + "path": "../../rrweb" + } + ] +} diff --git a/packages/plugins/rrweb-plugin-network-record/vite.config.ts b/packages/plugins/rrweb-plugin-network-record/vite.config.ts new file mode 100644 index 0000000000..1ffb811e62 --- /dev/null +++ b/packages/plugins/rrweb-plugin-network-record/vite.config.ts @@ -0,0 +1,3 @@ +import config from '../../../vite.config.default'; + +export default config('src/index.ts', 'rrwebPluginNetworkRecord'); diff --git a/packages/plugins/rrweb-plugin-network-replay/CHANGELOG.md b/packages/plugins/rrweb-plugin-network-replay/CHANGELOG.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/plugins/rrweb-plugin-network-replay/README.md b/packages/plugins/rrweb-plugin-network-replay/README.md new file mode 100644 index 0000000000..f43f991a8b --- /dev/null +++ b/packages/plugins/rrweb-plugin-network-replay/README.md @@ -0,0 +1,178 @@ +# @rrweb/rrweb-plugin-network-replay + +Please refer to the [network recipe](../../../docs/recipes/network.md) on how to use this plugin. +See the [guide](../../../guide.md) for more info on rrweb. + +## Sponsors + +[Become a sponsor](https://opencollective.com/rrweb#sponsor) and get your logo on our README on Github with a link to your site. + +### Gold Sponsors 🥇 + +
+ +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor + +
+ +### Silver Sponsors 🥈 + +
+ +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor + +
+ +### Bronze Sponsors 🥉 + +
+ +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor + +
+ +### Backers + + + +## Core Team Members + + + + + + + + +
+ + +
Yuyz0112 +

+
+
+ + +
Yun Feng +

+
+
+ + +
eoghanmurray +

+
+
+ + +
Juice10 +
open for rrweb consulting +
+
+ +## Who's using rrweb? + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + Smart screen recording for SaaS + +
+ + The first ever UX automation tool + + + + Remote Access & Co-Browsing + + + + The open source, fullstack Monitoring Platform. + + + + Comprehensive data analytics platform that empowers businesses to gain valuable insights and make data-driven decisions. + +
+ + Intercept, Modify, Record & Replay HTTP Requests. + + + + In-app bug reporting & customer feedback platform. + + + + Self-hosted website analytics with heatmaps and session recordings. + + + + Interactive product demos for small marketing teams + +
diff --git a/packages/plugins/rrweb-plugin-network-replay/package.json b/packages/plugins/rrweb-plugin-network-replay/package.json new file mode 100644 index 0000000000..a44b2eb9d0 --- /dev/null +++ b/packages/plugins/rrweb-plugin-network-replay/package.json @@ -0,0 +1,58 @@ +{ + "name": "@rrweb/rrweb-plugin-network-replay", + "version": "2.0.0-alpha.18", + "description": "", + "type": "module", + "main": "./dist/rrweb-plugin-network-replay.umd.cjs", + "module": "./dist/rrweb-plugin-network-replay.js", + "unpkg": "./dist/rrweb-plugin-network-replay.umd.cjs", + "typings": "dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/rrweb-plugin-network-replay.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/rrweb-plugin-network-replay.umd.cjs" + } + } + }, + "files": [ + "dist", + "package.json" + ], + "scripts": { + "dev": "vite build --watch", + "build": "yarn turbo run prepublish", + "check-types": "tsc -noEmit", + "prepublish": "tsc -noEmit && vite build" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/rrweb-io/rrweb.git" + }, + "keywords": [ + "rrweb" + ], + "author": "yanzhen@smartx.com", + "license": "MIT", + "bugs": { + "url": "https://github.com/rrweb-io/rrweb/issues" + }, + "homepage": "https://github.com/rrweb-io/rrweb#readme", + "devDependencies": { + "@rrweb/rrweb-plugin-network-record": "^2.0.0-alpha.18", + "@rrweb/types": "^2.0.0-alpha.18", + "rrweb": "^2.0.0-alpha.18", + "typescript": "^5.4.5", + "vite": "^5.3.1", + "vite-plugin-dts": "^3.9.1" + }, + "peerDependencies": { + "rrweb": "^2.0.0-alpha.18", + "@rrweb/types": "^2.0.0-alpha.18", + "@rrweb/utils": "^2.0.0-alpha.18" + } +} diff --git a/packages/plugins/rrweb-plugin-network-replay/src/index.ts b/packages/plugins/rrweb-plugin-network-replay/src/index.ts new file mode 100644 index 0000000000..7bedf76f56 --- /dev/null +++ b/packages/plugins/rrweb-plugin-network-replay/src/index.ts @@ -0,0 +1,28 @@ +import type { eventWithTime } from '@rrweb/types'; +import { EventType } from '@rrweb/types'; +import { PLUGIN_NAME } from '@rrweb/rrweb-plugin-network-record'; +import type { NetworkData } from '@rrweb/rrweb-plugin-network-record'; +import type { ReplayPlugin } from 'rrweb'; + +export type OnNetworkData = (data: NetworkData) => void; + +export type NetworkReplayOptions = { + onNetworkData: OnNetworkData; +}; + +export const getReplayNetworkPlugin: ( + options: NetworkReplayOptions, +) => ReplayPlugin = (options) => { + return { + handler(event: eventWithTime) { + if ( + event.type === EventType.Plugin && + event.data.plugin === PLUGIN_NAME + ) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const networkData = event.data.payload as NetworkData; + options.onNetworkData(networkData); + } + }, + }; +}; diff --git a/packages/plugins/rrweb-plugin-network-replay/tsconfig.json b/packages/plugins/rrweb-plugin-network-replay/tsconfig.json new file mode 100644 index 0000000000..01e3f2b7d8 --- /dev/null +++ b/packages/plugins/rrweb-plugin-network-replay/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../tsconfig.base.json", + "include": ["src"], + "exclude": ["vite.config.ts", "test"], + "compilerOptions": { + "rootDir": "src", + "tsBuildInfoFile": "./tsconfig.tsbuildinfo" + }, + "references": [ + { + "path": "../../rrweb" + } + ] +} diff --git a/packages/plugins/rrweb-plugin-network-replay/vite.config.ts b/packages/plugins/rrweb-plugin-network-replay/vite.config.ts new file mode 100644 index 0000000000..333e105d11 --- /dev/null +++ b/packages/plugins/rrweb-plugin-network-replay/vite.config.ts @@ -0,0 +1,3 @@ +import config from '../../../vite.config.default'; + +export default config('src/index.ts', 'rrwebPluginNetworkReplay'); diff --git a/tsconfig.json b/tsconfig.json index e738720dda..ebf327addc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,6 +24,12 @@ { "path": "packages/plugins/rrweb-plugin-console-record" }, + { + "path": "packages/plugins/rrweb-plugin-network-record" + }, + { + "path": "packages/plugins/rrweb-plugin-network-replay" + }, { "path": "packages/rrvideo" },