import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; import axios from 'axios'; import { App, Messages } from '@openreplay/tracker'; import { getExceptionMessage } from '@openreplay/tracker/lib/modules/exception.js'; // TODO: export from tracker root import { buildFullPath } from './url.js'; interface RequestData { body: BodyInit | null | undefined headers: Record } interface ResponseData { body: string | Object | null headers: Record } interface RequestResponseData { readonly status: number readonly method: string url: string request: RequestData response: ResponseData } export interface Options { sessionTokenHeader?: string; instance: AxiosInstance; failuresOnly: boolean; captureWhen: (AxiosRequestConfig) => boolean; ignoreHeaders: Array | boolean; sanitiser?: (RequestResponseData) => RequestResponseData | null; } // TODO: test webpack 5 for axios imports function isAxiosResponse(r: any): r is AxiosResponse { return typeof r === "object" && typeof r.config === "object" && typeof r.status === "number" } export default function(opts: Partial = {}) { const options: Options = Object.assign( { instance: axios, failuresOnly: false, captureWhen: () => true, ignoreHeaders: [ 'Cookie', 'Set-Cookie', 'Authorization' ], sanitiser: null, }, opts, ); return (app: App | null) => { if (app === null || // @ts-ignore - a catch for the developers who apply a plugin several times options.instance.__openreplayAxiosInstalled__ ) { return; } // @ts-ignore options.instance.__openreplayAxiosInstalled__ = true const ihOpt = options.ignoreHeaders const isHIgnoring = Array.isArray(ihOpt) ? name => ihOpt.includes(name) : () => ihOpt const sendFetchMessage = async (res: AxiosResponse) => { // ?? TODO: why config is undeined sometimes? if (!isAxiosResponse(res)) { return } // @ts-ignore const startTime: number = res.config.__openreplayStartTs; const duration = performance.now() - startTime; if (typeof startTime !== 'number') { return; } let reqBody: string = ''; if (typeof res.config.data === 'string') { reqBody = res.config.data; } else { try { reqBody = JSON.stringify(res.config.data) || ''; } catch (e) {} // TODO: app debug } let resBody: string = ''; if (typeof res.data === 'string') { resBody = res.data; } else { try { resBody = JSON.stringify(res.data) || ''; } catch (e) {} } const reqHs: Record = {} const resHs: Record = {} // TODO: type safe axios headers if (ihOpt !== true) { function writeReqHeader([n, v]: [string, string]) { if (!isHIgnoring(n)) { reqHs[n] = v } } if (res.config.headers instanceof Headers) { res.config.headers.forEach((v, n) => writeReqHeader([n, v])) } else if (Array.isArray(res.config.headers)) { res.config.headers.forEach(writeReqHeader); } else if (typeof res.config.headers === 'object') { Object.entries(res.config.headers as Record).forEach(writeReqHeader) } // TODO: type safe axios headers if (typeof res.headers === 'object') { Object.entries(res.headers as Record).forEach(([n, v]) => { if (!isHIgnoring(n)) resHs[n] = v }) } } // TODO: split the code on functions & files // Why can't axios propogate the final request URL somewhere? const url = buildFullPath(res.config.baseURL, options.instance.getUri(res.config)) const method = typeof res.config.method === 'string' ? res.config.method.toUpperCase() : 'GET' const status = res.status let reqResData: RequestResponseData | null = { status, method, url, request: { headers: reqHs, body: reqBody, }, response: { headers: resHs, body: resBody, }, } if (options.sanitiser) { try { reqResData.response.body = JSON.parse(resBody) as Object // Why the returning type is "any"? } catch {} reqResData = options.sanitiser(reqResData) if (!reqResData) { return } } const getStj = (r: RequestData | ResponseData): string => { if (r && typeof r.body !== 'string') { try { r.body = JSON.stringify(r.body) } catch { r.body = "" //app.log.warn("Openreplay fetch") // TODO: version check } } return JSON.stringify(r) } app.send( Messages.Fetch( method, String(reqResData.url), getStj(reqResData.request), getStj(reqResData.response), status, startTime + performance.timing.navigationStart, duration, ), ); } options.instance.interceptors.request.use(function (config) { if (typeof config !== "object") { return config } // ?? if (options.sessionTokenHeader) { const sessionToken = app.getSessionToken(); if (sessionToken) { if (config.headers === undefined) { config.headers = {}; } if (config.headers instanceof Headers) { config.headers.append(options.sessionTokenHeader, sessionToken); } else if (Array.isArray(config.headers)) { config.headers.push([options.sessionTokenHeader, sessionToken]); } else if (typeof config.headers === 'object') { config.headers[options.sessionTokenHeader] = sessionToken; } } } if (options.captureWhen(config)) { // TODO: use runWhen as axios publish a version with it // @ts-ignore config.__openreplayStartTs = performance.now(); } return config; }, function (error) { if (error instanceof Error) { app.send(getExceptionMessage(error, [])); } return Promise.reject(error); }, // { synchronous: true, /* runWhen: captureWhen // is not published in axios yet */ } ); options.instance.interceptors.response.use(function (response) { if (!options.failuresOnly) { sendFetchMessage(response); } return response; }, function (error) { if (axios.isAxiosError(error) && error.response) { sendFetchMessage(error.response) } else if (!axios.isCancel(error) && error instanceof Error) { app.send(getExceptionMessage(error, [])); } // TODO: common case (selector option) for modified responses if (isAxiosResponse(error)) { sendFetchMessage(error) } return Promise.reject(error); }); } }