feat(ios): network plugin for react native connector, tbt
This commit is contained in:
parent
50c1961105
commit
799978cd27
13 changed files with 1020 additions and 7 deletions
|
|
@ -46,7 +46,7 @@ target 'RntrackerExample' do
|
|||
:app_path => "#{Pod::Config.instance.installation_root}/.."
|
||||
)
|
||||
Dir['../../']
|
||||
pod 'Openreplay', '~> 1.0.7'
|
||||
pod 'Openreplay', '~> 1.0.8'
|
||||
|
||||
|
||||
target 'RntrackerExampleTests' do
|
||||
|
|
|
|||
|
|
@ -17,10 +17,10 @@ PODS:
|
|||
- hermes-engine/Pre-built (= 0.72.4)
|
||||
- hermes-engine/Pre-built (0.72.4)
|
||||
- libevent (2.1.12)
|
||||
- Openreplay (1.0.7):
|
||||
- Openreplay (1.0.8):
|
||||
- DeviceKit
|
||||
- SWCompression
|
||||
- openreplay-reactnative (0.1.1):
|
||||
- openreplay-reactnative (0.1.2):
|
||||
- Openreplay
|
||||
- RCT-Folly (= 2021.07.22.00)
|
||||
- React-Core
|
||||
|
|
@ -486,7 +486,7 @@ DEPENDENCIES:
|
|||
- glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`)
|
||||
- hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)
|
||||
- libevent (~> 2.1.12)
|
||||
- Openreplay (~> 1.0.7)
|
||||
- Openreplay (~> 1.0.8)
|
||||
- openreplay-reactnative (from `../..`)
|
||||
- RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`)
|
||||
- RCTRequired (from `../node_modules/react-native/Libraries/RCTRequired`)
|
||||
|
|
@ -627,8 +627,8 @@ SPEC CHECKSUMS:
|
|||
glog: 04b94705f318337d7ead9e6d17c019bd9b1f6b1b
|
||||
hermes-engine: 81191603c4eaa01f5e4ae5737a9efcf64756c7b2
|
||||
libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913
|
||||
Openreplay: 1361c805302bfa181a98a73b7f5536740126d06d
|
||||
openreplay-reactnative: 4d76871d1cffc6227f265d34c0fc67a3d61ebbf3
|
||||
Openreplay: 61de0723de9fa3e1dfef9058b93680cdc479be99
|
||||
openreplay-reactnative: 6d30623f1f38e711db1cf1bd783a56b80f8a444e
|
||||
RCT-Folly: 424b8c9a7a0b9ab2886ffe9c3b041ef628fd4fb1
|
||||
RCTRequired: c0569ecc035894e4a68baecb30fe6a7ea6e399f9
|
||||
RCTTypeSafety: e90354072c21236e0bcf1699011e39acd25fea2f
|
||||
|
|
@ -665,6 +665,6 @@ SPEC CHECKSUMS:
|
|||
SWCompression: 15e38b06c37077399a1b60bfecc1c2cd71f0ee99
|
||||
Yoga: 3efc43e0d48686ce2e8c60f99d4e6bd349aff981
|
||||
|
||||
PODFILE CHECKSUM: 09dc44ceb84074d4583819c2f2c91bbb3ecea54c
|
||||
PODFILE CHECKSUM: 71d4a7e6c76578117b61227a2f4ab0838c7711bc
|
||||
|
||||
COCOAPODS: 1.12.1
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ export default function App() {
|
|||
process.env.REACT_APP_INGEST
|
||||
);
|
||||
console.log('test', Openreplay.tracker, 123123);
|
||||
Openreplay.patchNetwork(
|
||||
global, () => false, {}
|
||||
)
|
||||
};
|
||||
|
||||
const setMedatada = () => {
|
||||
|
|
@ -27,6 +30,14 @@ export default function App() {
|
|||
Openreplay.tracker.setUserID('react-native@connector.me');
|
||||
};
|
||||
|
||||
const apiTest = () => {
|
||||
fetch('https://pokeapi.co/api/v2/pokemon/ditto').then((res) => {
|
||||
return res.json()
|
||||
}).then((res) => {
|
||||
console.log(res)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Openreplay.ORTouchTrackingView style={styles.container}>
|
||||
<View style={styles.container}>
|
||||
|
|
@ -48,6 +59,9 @@ export default function App() {
|
|||
<TouchableOpacity onPress={event}>
|
||||
<Text>event</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={apiTest}>
|
||||
<Text>Request</Text>
|
||||
</TouchableOpacity>
|
||||
<Openreplay.ORTrackedInput
|
||||
style={styles.input}
|
||||
onChangeText={onChangeNumber}
|
||||
|
|
|
|||
|
|
@ -8,5 +8,6 @@ RCT_EXTERN_METHOD(setMetadata:(NSString *)key value:(NSString *)value)
|
|||
RCT_EXTERN_METHOD(event:(NSString *)name object:(NSString *)object)
|
||||
RCT_EXTERN_METHOD(setUserID:(NSString *)userID)
|
||||
RCT_EXTERN_METHOD(userAnonymousID:(NSString *)userID)
|
||||
RCT_EXTERN_METHOD(networkRequest:(NSString *)url method:(NSString *)method requestJSON:(NSString *)requestJSON responseJSON:(NSString *)responseJSON status:(nonnull NSNumber *)status duration:(nonnull NSNumber *)duration)
|
||||
@end
|
||||
|
||||
|
|
|
|||
|
|
@ -64,4 +64,9 @@ public class ORTrackerConnector: NSObject {
|
|||
open func userAnonymousID(_ userID: String) {
|
||||
Openreplay.shared.userAnonymousID(userID)
|
||||
}
|
||||
|
||||
@objc(networkRequest:method:requestJSON:responseJSON:status:duration:)
|
||||
open func networkRequest(_ url: String, method: String, requestJSON: String, responseJSON: String, status: NSNumber, duration: NSNumber) {
|
||||
Openreplay.shared.networkRequest(url: url, method: method, requestJSON: requestJSON, responseJSON: responseJSON, status: Int(truncating: status), duration: UInt64(truncating: duration))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
297
tracker/tracker-reactnative/src/Network/fetchProxy.ts
Normal file
297
tracker/tracker-reactnative/src/Network/fetchProxy.ts
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
// @ts-nocheck
|
||||
/**
|
||||
* I took inspiration in few stack exchange posts
|
||||
* and Tencent vConsole library (MIT)
|
||||
* by wrapping the XMLHttpRequest object in a Proxy
|
||||
* we can intercept the network requests
|
||||
* in not-so-hacky way
|
||||
* */
|
||||
import NetworkMessage, { RequestState } from './networkMessage'
|
||||
import { formatByteSize, genStringBody, getStringResponseByType, getURL } from './utils'
|
||||
import { RequestResponseData } from './types'
|
||||
|
||||
export class ResponseProxyHandler<T extends Response> implements ProxyHandler<T> {
|
||||
public resp: Response
|
||||
public item: NetworkMessage
|
||||
|
||||
constructor(resp: T, item: NetworkMessage) {
|
||||
this.resp = resp
|
||||
this.item = item
|
||||
this.mockReader()
|
||||
}
|
||||
|
||||
public set(target: T, key: string, value: (args: any[]) => any) {
|
||||
return Reflect.set(target, key, value)
|
||||
}
|
||||
|
||||
public get(target: T, key: string) {
|
||||
const value = Reflect.get(target, key)
|
||||
switch (key) {
|
||||
case 'arrayBuffer':
|
||||
case 'blob':
|
||||
case 'formData':
|
||||
case 'json':
|
||||
case 'text':
|
||||
return () => {
|
||||
this.item.responseType = <any>key.toLowerCase()
|
||||
// @ts-ignore
|
||||
return value.apply(target).then((resp: any) => {
|
||||
this.item.response = getStringResponseByType(this.item.responseType, resp)
|
||||
return resp
|
||||
})
|
||||
}
|
||||
}
|
||||
if (typeof value === 'function') {
|
||||
return value.bind(target)
|
||||
} else {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
protected mockReader() {
|
||||
let readerReceivedValue: Uint8Array
|
||||
if (!this.resp.body) {
|
||||
// some browsers do not return `body` in some cases, like `OPTIONS` method
|
||||
return
|
||||
}
|
||||
if (typeof this.resp.body.getReader !== 'function') {
|
||||
return
|
||||
}
|
||||
const _getReader = this.resp.body.getReader
|
||||
// @ts-ignore
|
||||
this.resp.body.getReader = () => {
|
||||
const reader = <ReturnType<typeof _getReader>>_getReader.apply(this.resp.body)
|
||||
|
||||
// when readyState is already 4,
|
||||
// it's not a chunked stream, or it had already been read.
|
||||
// so should not update status.
|
||||
if (this.item.readyState === RequestState.DONE) {
|
||||
return reader
|
||||
}
|
||||
|
||||
const _read = reader.read
|
||||
const _cancel = reader.cancel
|
||||
this.item.responseType = 'arraybuffer'
|
||||
|
||||
// @ts-ignore
|
||||
reader.read = () => {
|
||||
return (<ReturnType<typeof _read>>_read.apply(reader)).then(
|
||||
(result: any) => {
|
||||
if (!readerReceivedValue) {
|
||||
// @ts-ignore
|
||||
readerReceivedValue = new Uint8Array(result.value)
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const newValue = new Uint8Array(readerReceivedValue.length + result.value!.length)
|
||||
newValue.set(readerReceivedValue)
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
newValue.set(result.value!, readerReceivedValue.length)
|
||||
readerReceivedValue = newValue
|
||||
}
|
||||
this.item.endTime = performance.now()
|
||||
this.item.duration = this.item.endTime - (this.item.startTime || this.item.endTime)
|
||||
this.item.readyState = result.done ? 4 : 3
|
||||
this.item.statusText = result.done ? String(this.item.status) : 'Loading'
|
||||
this.item.responseSize = readerReceivedValue.length
|
||||
this.item.responseSizeText = formatByteSize(this.item.responseSize)
|
||||
if (result.done) {
|
||||
this.item.response = getStringResponseByType(
|
||||
this.item.responseType,
|
||||
readerReceivedValue,
|
||||
)
|
||||
}
|
||||
return result
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
reader.cancel = (...args) => {
|
||||
this.item.cancelState = 2
|
||||
this.item.statusText = 'Cancel'
|
||||
this.item.endTime = performance.now()
|
||||
this.item.duration = this.item.endTime - (this.item.startTime || this.item.endTime)
|
||||
this.item.response = getStringResponseByType(this.item.responseType, readerReceivedValue)
|
||||
return _cancel.apply(reader, args)
|
||||
}
|
||||
return reader
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class FetchProxyHandler<T extends typeof fetch> implements ProxyHandler<T> {
|
||||
constructor(
|
||||
private readonly ignoredHeaders: boolean | string[],
|
||||
private readonly sanitize: (data: RequestResponseData) => RequestResponseData,
|
||||
private readonly sendMessage: (item: any) => void,
|
||||
private readonly isServiceUrl: (url: string) => boolean,
|
||||
) {}
|
||||
|
||||
public apply(target: T, _: typeof global, argsList: [RequestInfo | URL, RequestInit]) {
|
||||
const input = argsList[0]
|
||||
const init = argsList[1]
|
||||
if (
|
||||
!input ||
|
||||
// @ts-ignore
|
||||
(typeof input !== 'string' && !input?.url)
|
||||
) {
|
||||
return <ReturnType<T>>target.apply(global, argsList)
|
||||
}
|
||||
|
||||
const isORUrl =
|
||||
input instanceof URL || typeof input === 'string'
|
||||
? this.isServiceUrl(String(input))
|
||||
: this.isServiceUrl(String(input.url))
|
||||
|
||||
if (isORUrl) {
|
||||
return target.apply(global, argsList)
|
||||
}
|
||||
|
||||
const item = new NetworkMessage(this.ignoredHeaders, this.sanitize)
|
||||
this.beforeFetch(item, input as RequestInfo, init)
|
||||
|
||||
return (<ReturnType<T>>target.apply(global, argsList))
|
||||
.then(this.afterFetch(item))
|
||||
.catch((e) => {
|
||||
// mock finally
|
||||
item.endTime = performance.now()
|
||||
item.duration = item.endTime - (item.startTime || item.endTime)
|
||||
throw e
|
||||
})
|
||||
}
|
||||
|
||||
protected beforeFetch(item: NetworkMessage, input: RequestInfo | string, init?: RequestInit) {
|
||||
let url: URL,
|
||||
method = 'GET',
|
||||
requestHeader: any = {}
|
||||
|
||||
// handle `input` content
|
||||
if (typeof input === 'string') {
|
||||
// when `input` is a string
|
||||
method = init?.method || 'GET'
|
||||
url = getURL(input)
|
||||
requestHeader = init?.headers || {}
|
||||
} else {
|
||||
// when `input` is a `Request` object
|
||||
method = input.method || 'GET'
|
||||
url = getURL(input.url)
|
||||
requestHeader = input.headers
|
||||
}
|
||||
|
||||
item.method = <NetworkMessage['method']>method
|
||||
item.requestType = 'fetch'
|
||||
item.requestHeader = requestHeader
|
||||
item.url = url.toString()
|
||||
item.name = (item.url.split('/')[3] || '') + item.url.split('?')[1]
|
||||
item.status = 0
|
||||
item.statusText = 'Pending'
|
||||
item.readyState = 1
|
||||
if (!item.startTime) {
|
||||
// UNSENT
|
||||
item.startTime = performance.now()
|
||||
}
|
||||
|
||||
if (Object.prototype.toString.call(requestHeader) === '[object Headers]') {
|
||||
item.requestHeader = {}
|
||||
for (const [key, value] of <Headers>requestHeader) {
|
||||
item.requestHeader[key] = value
|
||||
}
|
||||
} else {
|
||||
item.requestHeader = requestHeader
|
||||
}
|
||||
|
||||
// save GET data
|
||||
const search = url.toString().split('?')[1]
|
||||
const searchParams = new URLSearchParams(search)
|
||||
if (search && searchParams) {
|
||||
item.getData = {}
|
||||
for (const [key, value] of searchParams) {
|
||||
item.getData[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
// save POST data
|
||||
if (init?.body) {
|
||||
item.requestData = genStringBody(init.body)
|
||||
}
|
||||
}
|
||||
|
||||
protected afterFetch(item: NetworkMessage) {
|
||||
return (resp: Response) => {
|
||||
item.endTime = performance.now()
|
||||
item.duration = item.endTime - (item.startTime || item.endTime)
|
||||
item.status = resp.status
|
||||
item.statusText = String(resp.status)
|
||||
|
||||
let isChunked = false
|
||||
item.header = {}
|
||||
for (const [key, value] of resp.headers) {
|
||||
item.header[key] = value
|
||||
isChunked = value.toLowerCase().indexOf('chunked') > -1 ? true : isChunked
|
||||
}
|
||||
|
||||
if (isChunked) {
|
||||
// when `transfer-encoding` is chunked, the response is a stream which is under loading,
|
||||
// so the `readyState` should be 3 (Loading),
|
||||
// and the response should NOT be `clone()` which will affect stream reading.
|
||||
item.readyState = 3
|
||||
} else {
|
||||
// Otherwise, not chunked, the response is not a stream,
|
||||
// so it's completed and can be cloned for `text()` calling.
|
||||
item.readyState = 4
|
||||
|
||||
void this.handleResponseBody(resp.clone(), item).then(
|
||||
(responseValue: string | ArrayBuffer) => {
|
||||
item.responseSize =
|
||||
typeof responseValue === 'string' ? responseValue.length : responseValue.byteLength
|
||||
item.responseSizeText = formatByteSize(item.responseSize)
|
||||
item.response = getStringResponseByType(item.responseType, responseValue)
|
||||
|
||||
const msg = item.getMessage()
|
||||
this.sendMessage(msg[0], msg[1], msg[2], msg[3], msg[4], msg[5])
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return new Proxy(resp, new ResponseProxyHandler(resp, item))
|
||||
}
|
||||
}
|
||||
|
||||
protected handleResponseBody(resp: Response, item: NetworkMessage) {
|
||||
// parse response body by Content-Type
|
||||
const contentType = resp.headers.get('content-type')
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
item.responseType = 'json'
|
||||
return resp.text()
|
||||
} else if (
|
||||
contentType &&
|
||||
(contentType.includes('text/html') || contentType.includes('text/plain'))
|
||||
) {
|
||||
item.responseType = 'text'
|
||||
return resp.text()
|
||||
} else {
|
||||
item.responseType = 'arraybuffer'
|
||||
return resp.arrayBuffer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default class FetchProxy {
|
||||
public static create(
|
||||
ignoredHeaders: boolean | string[],
|
||||
sanitize: (data: RequestResponseData) => RequestResponseData,
|
||||
sendMessage: (item: any) => void,
|
||||
isServiceUrl: (url: string) => boolean,
|
||||
tokenUrlMatcher?: (url: string) => boolean,
|
||||
) {
|
||||
return new Proxy(
|
||||
fetch,
|
||||
new FetchProxyHandler(
|
||||
ignoredHeaders,
|
||||
sanitize,
|
||||
sendMessage,
|
||||
isServiceUrl,
|
||||
tokenUrlMatcher,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
31
tracker/tracker-reactnative/src/Network/index.ts
Normal file
31
tracker/tracker-reactnative/src/Network/index.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import FetchProxy from './fetchProxy'
|
||||
import XHRProxy from './xhrProxy'
|
||||
import type { RequestResponseData } from './types'
|
||||
|
||||
export default function setProxy(
|
||||
context: typeof globalThis,
|
||||
ignoredHeaders: boolean | string[],
|
||||
sanitize: (data: RequestResponseData) => RequestResponseData,
|
||||
sendMessage: (message: any) => void,
|
||||
isServiceUrl: (url: string) => boolean,
|
||||
tokenUrlMatcher?: (url: string) => boolean,
|
||||
) {
|
||||
if (context.XMLHttpRequest) {
|
||||
context.XMLHttpRequest = XHRProxy.create(
|
||||
ignoredHeaders,
|
||||
sanitize,
|
||||
sendMessage,
|
||||
isServiceUrl,
|
||||
tokenUrlMatcher,
|
||||
)
|
||||
}
|
||||
if (context.fetch) {
|
||||
context.fetch = FetchProxy.create(
|
||||
ignoredHeaders,
|
||||
sanitize,
|
||||
sendMessage,
|
||||
isServiceUrl,
|
||||
tokenUrlMatcher,
|
||||
)
|
||||
}
|
||||
}
|
||||
109
tracker/tracker-reactnative/src/Network/networkMessage.ts
Normal file
109
tracker/tracker-reactnative/src/Network/networkMessage.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import type { RequestResponseData } from './types';
|
||||
|
||||
export type httpMethod =
|
||||
// '' is a rare case of error
|
||||
| ''
|
||||
| 'GET'
|
||||
| 'POST'
|
||||
| 'PUT'
|
||||
| 'DELETE'
|
||||
| 'HEAD'
|
||||
| 'CONNECT'
|
||||
| 'OPTIONS'
|
||||
| 'TRACE'
|
||||
| 'PATCH';
|
||||
|
||||
export enum RequestState {
|
||||
UNSENT = 0,
|
||||
OPENED = 1,
|
||||
HEADERS_RECEIVED = 2,
|
||||
LOADING = 3,
|
||||
DONE = 4,
|
||||
}
|
||||
|
||||
/**
|
||||
* I know we're not using most of the information from this class
|
||||
* but it can be useful in the future if we will decide to display more stuff in our ui
|
||||
* */
|
||||
|
||||
export default class NetworkMessage {
|
||||
id = '';
|
||||
name?: string = '';
|
||||
method: httpMethod = '';
|
||||
url = '';
|
||||
status = 0;
|
||||
statusText?: string = '';
|
||||
cancelState?: 0 | 1 | 2 | 3 = 0;
|
||||
readyState?: RequestState = 0;
|
||||
header: { [key: string]: string } = {};
|
||||
responseType: XMLHttpRequest['responseType'] = '';
|
||||
requestType: 'xhr' | 'fetch' | 'ping' | 'custom' | 'beacon' = 'xhr'
|
||||
requestHeader: any = {};
|
||||
response: any;
|
||||
responseSize = 0; // bytes
|
||||
responseSizeText = '';
|
||||
startTime = 0;
|
||||
endTime = 0;
|
||||
duration = 0;
|
||||
getData: { [key: string]: string } = {};
|
||||
requestData: string | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly ignoredHeaders: boolean | string[] = [],
|
||||
private readonly sanitize: (
|
||||
data: RequestResponseData
|
||||
) => RequestResponseData
|
||||
) {}
|
||||
|
||||
getMessage() {
|
||||
const { reqHs, resHs } = this.writeHeaders();
|
||||
const request = {
|
||||
headers: reqHs,
|
||||
body:
|
||||
this.method === 'GET' ? JSON.stringify(this.getData) : this.requestData,
|
||||
};
|
||||
const response = { headers: resHs, body: this.response };
|
||||
|
||||
const messageInfo = this.sanitize({
|
||||
url: this.url,
|
||||
method: this.method,
|
||||
status: this.status,
|
||||
request,
|
||||
response,
|
||||
});
|
||||
|
||||
return [
|
||||
messageInfo.url,
|
||||
messageInfo.method,
|
||||
JSON.stringify(messageInfo.request),
|
||||
JSON.stringify(messageInfo.response),
|
||||
messageInfo.status ?? 0,
|
||||
this.duration ?? 0,
|
||||
]
|
||||
}
|
||||
|
||||
writeHeaders() {
|
||||
const reqHs: Record<string, string> = {};
|
||||
Object.entries(this.requestHeader).forEach(([key, value]) => {
|
||||
if (this.isHeaderIgnored(key)) return;
|
||||
// @ts-ignore
|
||||
reqHs[key] = value;
|
||||
});
|
||||
const resHs: Record<string, string> = {};
|
||||
Object.entries(this.header).forEach(([key, value]) => {
|
||||
if (this.isHeaderIgnored(key)) return;
|
||||
resHs[key] = value;
|
||||
});
|
||||
return { reqHs, resHs };
|
||||
}
|
||||
|
||||
isHeaderIgnored(key: string) {
|
||||
if (Array.isArray(this.ignoredHeaders)) {
|
||||
return this.ignoredHeaders
|
||||
.map((k) => k.toLowerCase())
|
||||
.includes(key.toLowerCase());
|
||||
} else {
|
||||
return this.ignoredHeaders;
|
||||
}
|
||||
}
|
||||
}
|
||||
15
tracker/tracker-reactnative/src/Network/types.ts
Normal file
15
tracker/tracker-reactnative/src/Network/types.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
export interface RequestResponseData {
|
||||
readonly status: number
|
||||
readonly method: string
|
||||
url: string
|
||||
request: {
|
||||
body: string | null
|
||||
headers: Record<string, string>
|
||||
}
|
||||
response: {
|
||||
body: string | null
|
||||
headers: Record<string, string>
|
||||
}
|
||||
}
|
||||
|
||||
// we only support sanitizing for json/string data because how you're gonna sanitize binary data?
|
||||
207
tracker/tracker-reactnative/src/Network/utils.ts
Normal file
207
tracker/tracker-reactnative/src/Network/utils.ts
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
// @ts-nocheck
|
||||
|
||||
export const genResponseByType = (
|
||||
responseType: XMLHttpRequest['responseType'],
|
||||
response: any,
|
||||
): string | Record<string, any> => {
|
||||
let result = ''
|
||||
switch (responseType) {
|
||||
case '':
|
||||
case 'text':
|
||||
case 'json':
|
||||
if (typeof response == 'string') {
|
||||
try {
|
||||
result = JSON.parse(response)
|
||||
} catch (e) {
|
||||
// not a JSON string
|
||||
result = response.slice(0, 10000)
|
||||
}
|
||||
} else if (isPureObject(response) || Array.isArray(response)) {
|
||||
result = JSON.stringify(response)
|
||||
} else if (typeof response !== 'undefined') {
|
||||
result = Object.prototype.toString.call(response)
|
||||
}
|
||||
break
|
||||
|
||||
case 'blob':
|
||||
case 'document':
|
||||
case 'arraybuffer':
|
||||
default:
|
||||
if (typeof response !== 'undefined') {
|
||||
result = Object.prototype.toString.call(response)
|
||||
}
|
||||
break
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export const getStringResponseByType = (
|
||||
responseType: XMLHttpRequest['responseType'],
|
||||
response: any,
|
||||
) => {
|
||||
let result = ''
|
||||
switch (responseType) {
|
||||
case '':
|
||||
case 'text':
|
||||
case 'json':
|
||||
if (typeof response == 'string') {
|
||||
result = response
|
||||
} else if (isPureObject(response) || Array.isArray(response)) {
|
||||
result = JSON.stringify(response)
|
||||
} else if (typeof response !== 'undefined') {
|
||||
result = Object.prototype.toString.call(response)
|
||||
}
|
||||
break
|
||||
case 'blob':
|
||||
case 'document':
|
||||
case 'arraybuffer':
|
||||
default:
|
||||
if (typeof response !== 'undefined') {
|
||||
result = Object.prototype.toString.call(response)
|
||||
}
|
||||
break
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export const genStringBody = (body?: BodyInit) => {
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
let result: string
|
||||
|
||||
if (typeof body === 'string') {
|
||||
if (body[0] === '{' || body[0] === '[') {
|
||||
result = body
|
||||
}
|
||||
// 'a=1&b=2' => try to parse as query
|
||||
const arr = body.split('&')
|
||||
if (arr.length === 1) {
|
||||
// not a query, parse as original string
|
||||
result = body
|
||||
} else {
|
||||
// 'a=1&b=2&c' => parse as query
|
||||
result = arr.join(',')
|
||||
}
|
||||
} else if (isIterable(body)) {
|
||||
// FormData or URLSearchParams or Array
|
||||
const arr = []
|
||||
for (const [key, value] of <FormData | URLSearchParams>body) {
|
||||
arr.push(`${key}=${typeof value === 'string' ? value : '[object Object]'}`)
|
||||
}
|
||||
result = arr.join(',')
|
||||
} else if (
|
||||
body instanceof Blob ||
|
||||
body instanceof ReadableStream ||
|
||||
body instanceof ArrayBuffer
|
||||
) {
|
||||
result = 'byte data'
|
||||
} else if (isPureObject(body)) {
|
||||
// overriding ArrayBufferView which is not convertable to string
|
||||
result = <any>body
|
||||
} else {
|
||||
result = `can't parse body ${typeof body}`
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export const genGetDataByUrl = (url: string, getData: Record<string, any> = {}) => {
|
||||
if (!isPureObject(getData)) {
|
||||
getData = {}
|
||||
}
|
||||
let query: string[] = url ? url.split('?') : [] // a.php?b=c&d=?e => ['a.php', 'b=c&d=', 'e']
|
||||
query.shift() // => ['b=c&d=', 'e']
|
||||
if (query.length > 0) {
|
||||
query = query.join('?').split('&') // => 'b=c&d=?e' => ['b=c', 'd=?e']
|
||||
for (const q of query) {
|
||||
const kv = q.split('=')
|
||||
try {
|
||||
getData[kv[0]] = decodeURIComponent(kv[1])
|
||||
} catch (e) {
|
||||
// "URIError: URI malformed" will be thrown when `kv[1]` contains "%", so just use raw data
|
||||
// @issue #470
|
||||
// @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Malformed_URI
|
||||
getData[kv[0]] = kv[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return getData
|
||||
}
|
||||
|
||||
export const genFormattedBody = (body?: BodyInit) => {
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
let result: string | { [key: string]: string }
|
||||
|
||||
if (typeof body === 'string') {
|
||||
try {
|
||||
// '{a:1}' =>
|
||||
result = JSON.parse(body)
|
||||
} catch (e) {
|
||||
// 'a=1&b=2' => try to parse as query
|
||||
const arr = body.split('&')
|
||||
result = {}
|
||||
// eslint-disable-next-line
|
||||
for (let q of arr) {
|
||||
const kv = q.split('=')
|
||||
result[kv[0]] = kv[1] === undefined ? 'undefined' : kv[1]
|
||||
}
|
||||
}
|
||||
} else if (isIterable(body)) {
|
||||
// FormData or URLSearchParams or Array
|
||||
result = {}
|
||||
for (const [key, value] of <FormData | URLSearchParams>body) {
|
||||
result[key] = typeof value === 'string' ? value : '[object Object]'
|
||||
}
|
||||
} else if (
|
||||
body instanceof Blob ||
|
||||
body instanceof ReadableStream ||
|
||||
body instanceof ArrayBuffer
|
||||
) {
|
||||
result = 'byte data'
|
||||
} else if (isPureObject(body)) {
|
||||
// overriding ArrayBufferView which is not convertable to string
|
||||
result = <any>body
|
||||
} else {
|
||||
result = `can't parse body ${typeof body}`
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function isPureObject(input: any): input is Record<any, any> {
|
||||
return null !== input && typeof input === 'object'
|
||||
}
|
||||
|
||||
export function isIterable(value: any) {
|
||||
if (value === null || value === undefined) {
|
||||
return false
|
||||
}
|
||||
return typeof Symbol !== 'undefined' && typeof value[Symbol.iterator] === 'function'
|
||||
}
|
||||
|
||||
export function formatByteSize(bytes: number) {
|
||||
if (bytes <= 0) {
|
||||
// shouldn't happen?
|
||||
return ''
|
||||
}
|
||||
if (bytes >= 1000 * 1000) {
|
||||
return (bytes / 1000 / 1000).toFixed(1) + ' MB'
|
||||
}
|
||||
if (bytes >= 1000) {
|
||||
return (bytes / 1000).toFixed(1) + ' KB'
|
||||
}
|
||||
return `${bytes}B`
|
||||
}
|
||||
|
||||
export const getURL = (urlString: string) => {
|
||||
if (urlString.startsWith('//')) {
|
||||
const baseUrl = new URL(window.location.href)
|
||||
urlString = `${baseUrl.protocol}${urlString}`
|
||||
}
|
||||
if (urlString.startsWith('http')) {
|
||||
return new URL(urlString)
|
||||
} else {
|
||||
return new URL(urlString, window.location.href)
|
||||
}
|
||||
}
|
||||
247
tracker/tracker-reactnative/src/Network/xhrProxy.ts
Normal file
247
tracker/tracker-reactnative/src/Network/xhrProxy.ts
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
// @ts-nocheck
|
||||
/**
|
||||
* I took inspiration in few stack exchange posts
|
||||
* and Tencent vConsole library (MIT)
|
||||
* by wrapping the XMLHttpRequest object in a Proxy
|
||||
* we can intercept the network requests
|
||||
* in not-so-hacky way
|
||||
* */
|
||||
|
||||
import NetworkMessage, { RequestState } from './networkMessage'
|
||||
import { genGetDataByUrl, formatByteSize, genStringBody, getStringResponseByType } from './utils'
|
||||
import { RequestResponseData } from './types'
|
||||
|
||||
export class XHRProxyHandler<T extends XMLHttpRequest> implements ProxyHandler<T> {
|
||||
public XMLReq: XMLHttpRequest
|
||||
public item: NetworkMessage
|
||||
|
||||
constructor(
|
||||
XMLReq: XMLHttpRequest,
|
||||
private readonly ignoredHeaders: boolean | string[],
|
||||
private readonly sanitize: (data: RequestResponseData) => RequestResponseData,
|
||||
private readonly sendMessage: (message: any) => void,
|
||||
private readonly isServiceUrl: (url: string) => boolean,
|
||||
private readonly tokenUrlMatcher?: (url: string) => boolean,
|
||||
) {
|
||||
this.XMLReq = XMLReq
|
||||
this.XMLReq.onreadystatechange = () => {
|
||||
this.onReadyStateChange()
|
||||
}
|
||||
this.XMLReq.onabort = () => {
|
||||
this.onAbort()
|
||||
}
|
||||
this.XMLReq.ontimeout = () => {
|
||||
this.onTimeout()
|
||||
}
|
||||
this.item = new NetworkMessage(ignoredHeaders, sanitize)
|
||||
this.item.requestType = 'xhr'
|
||||
}
|
||||
|
||||
public get(target: T, key: string) {
|
||||
switch (key) {
|
||||
case 'open':
|
||||
return this.getOpen(target)
|
||||
case 'send':
|
||||
return this.getSend(target)
|
||||
case 'setRequestHeader':
|
||||
return this.getSetRequestHeader(target)
|
||||
default:
|
||||
// eslint-disable-next-line no-case-declarations
|
||||
const value = Reflect.get(target, key)
|
||||
if (typeof value === 'function') {
|
||||
return value.bind(target)
|
||||
} else {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public set(target: T, key: string, value: (args: any[]) => any) {
|
||||
switch (key) {
|
||||
case 'onreadystatechange':
|
||||
return this.setOnReadyStateChange(target, key, value)
|
||||
case 'onabort':
|
||||
return this.setOnAbort(target, key, value)
|
||||
case 'ontimeout':
|
||||
return this.setOnTimeout(target, key, value)
|
||||
default:
|
||||
// not tracked methods
|
||||
}
|
||||
return Reflect.set(target, key, value)
|
||||
}
|
||||
|
||||
public onReadyStateChange() {
|
||||
if (this.item.url && this.isServiceUrl(this.item.url)) return
|
||||
this.item.readyState = this.XMLReq.readyState
|
||||
this.item.responseType = this.XMLReq.responseType
|
||||
this.item.endTime = performance.now()
|
||||
this.item.duration = this.item.endTime - this.item.startTime
|
||||
this.updateItemByReadyState()
|
||||
setTimeout(() => {
|
||||
this.item.response = getStringResponseByType(this.item.responseType, this.item.response)
|
||||
}, 0)
|
||||
|
||||
if (this.XMLReq.readyState === RequestState.DONE) {
|
||||
const msg = this.item.getMessage()
|
||||
this.sendMessage(msg[0], msg[1], msg[2], msg[3], msg[4], msg[5])
|
||||
}
|
||||
}
|
||||
|
||||
public onAbort() {
|
||||
this.item.cancelState = 1
|
||||
this.item.statusText = 'Abort'
|
||||
|
||||
const msg = this.item.getMessage()
|
||||
this.sendMessage(msg[0], msg[1], msg[2], msg[3], msg[4], msg[5])
|
||||
}
|
||||
|
||||
public onTimeout() {
|
||||
this.item.cancelState = 3
|
||||
this.item.statusText = 'Timeout'
|
||||
|
||||
const msg = this.item.getMessage()
|
||||
this.sendMessage(msg[0], msg[1], msg[2], msg[3], msg[4], msg[5])
|
||||
}
|
||||
|
||||
protected getOpen(target: T) {
|
||||
const targetFunction = Reflect.get(target, 'open')
|
||||
return (...args: any[]) => {
|
||||
const method = args[0]
|
||||
const url = args[1]
|
||||
this.item.method = method ? method.toUpperCase() : 'GET'
|
||||
this.item.url = url || ''
|
||||
this.item.name = this.item.url.replace(new RegExp('/*$'), '').split('/').pop() || ''
|
||||
this.item.getData = genGetDataByUrl(this.item.url, {})
|
||||
return targetFunction.apply(target, args)
|
||||
}
|
||||
}
|
||||
|
||||
protected getSend(target: T) {
|
||||
const targetFunction = Reflect.get(target, 'send')
|
||||
return (...args: any[]) => {
|
||||
const data: XMLHttpRequestBodyInit = args[0]
|
||||
this.item.requestData = genStringBody(data)
|
||||
return targetFunction.apply(target, args)
|
||||
}
|
||||
}
|
||||
|
||||
protected getSetRequestHeader(target: T) {
|
||||
const targetFunction = Reflect.get(target, 'setRequestHeader')
|
||||
return (...args: any[]) => {
|
||||
if (!this.item.requestHeader) {
|
||||
this.item.requestHeader = {}
|
||||
}
|
||||
// @ts-ignore
|
||||
this.item.requestHeader[args[0]] = args[1]
|
||||
return targetFunction.apply(target, args)
|
||||
}
|
||||
}
|
||||
|
||||
protected setOnReadyStateChange(target: T, key: string, orscFunction: (args: any[]) => any) {
|
||||
return Reflect.set(target, key, (...args: any[]) => {
|
||||
this.onReadyStateChange()
|
||||
orscFunction?.apply(target, args)
|
||||
})
|
||||
}
|
||||
|
||||
protected setOnAbort(target: T, key: string, oaFunction: (args: any[]) => any) {
|
||||
return Reflect.set(target, key, (...args: any[]) => {
|
||||
this.onAbort()
|
||||
oaFunction.apply(target, args)
|
||||
})
|
||||
}
|
||||
|
||||
protected setOnTimeout(target: T, key: string, otFunction: (args: any[]) => any) {
|
||||
return Reflect.set(target, key, (...args: any[]) => {
|
||||
this.onTimeout()
|
||||
otFunction.apply(target, args)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update item's properties according to readyState.
|
||||
*/
|
||||
protected updateItemByReadyState() {
|
||||
switch (this.XMLReq.readyState) {
|
||||
case RequestState.UNSENT:
|
||||
case RequestState.OPENED:
|
||||
this.item.status = RequestState.UNSENT
|
||||
this.item.statusText = 'Pending'
|
||||
if (!this.item.startTime) {
|
||||
this.item.startTime = performance.now()
|
||||
}
|
||||
break
|
||||
case RequestState.HEADERS_RECEIVED:
|
||||
this.item.status = this.XMLReq.status
|
||||
this.item.statusText = 'Loading'
|
||||
this.item.header = {}
|
||||
// eslint-disable-next-line no-case-declarations
|
||||
const header = this.XMLReq.getAllResponseHeaders() || '',
|
||||
headerArr = header.split('\n')
|
||||
// extract plain text to key-value format
|
||||
for (let i = 0; i < headerArr.length; i++) {
|
||||
const line = headerArr[i]
|
||||
if (!line) {
|
||||
continue
|
||||
}
|
||||
const arr = line.split(': ')
|
||||
const key = arr[0]
|
||||
this.item.header[key] = arr.slice(1).join(': ')
|
||||
}
|
||||
break
|
||||
case RequestState.LOADING:
|
||||
this.item.status = this.XMLReq.status
|
||||
this.item.statusText = 'Loading'
|
||||
if (!!this.XMLReq.response && this.XMLReq.response.length) {
|
||||
this.item.responseSize = this.XMLReq.response.length
|
||||
this.item.responseSizeText = formatByteSize(this.item.responseSize)
|
||||
}
|
||||
break
|
||||
case RequestState.DONE:
|
||||
// `XMLReq.abort()` will change `status` from 200 to 0, so use previous value in this case
|
||||
this.item.status = this.XMLReq.status || this.item.status || 0
|
||||
// show status code when request completed
|
||||
this.item.statusText = String(this.item.status)
|
||||
this.item.endTime = performance.now()
|
||||
this.item.duration = this.item.endTime - (this.item.startTime || this.item.endTime)
|
||||
this.item.response = this.XMLReq.response
|
||||
|
||||
if (!!this.XMLReq.response && this.XMLReq.response.length) {
|
||||
this.item.responseSize = this.XMLReq.response.length
|
||||
this.item.responseSizeText = formatByteSize(this.item.responseSize)
|
||||
}
|
||||
break
|
||||
default:
|
||||
this.item.status = this.XMLReq.status
|
||||
this.item.statusText = 'Unknown'
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default class XHRProxy {
|
||||
public static create(
|
||||
ignoredHeaders: boolean | string[],
|
||||
sanitize: (data: RequestResponseData) => RequestResponseData,
|
||||
sendMessage: (data: any) => void,
|
||||
isServiceUrl: (url: string) => boolean,
|
||||
tokenUrlMatcher?: (url: string) => boolean,
|
||||
) {
|
||||
return new Proxy(XMLHttpRequest, {
|
||||
construct(original: any) {
|
||||
const XMLReq = new original()
|
||||
return new Proxy(
|
||||
XMLReq,
|
||||
new XHRProxyHandler(
|
||||
XMLReq as XMLHttpRequest,
|
||||
ignoredHeaders,
|
||||
sanitize,
|
||||
sendMessage,
|
||||
isServiceUrl,
|
||||
tokenUrlMatcher,
|
||||
),
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,8 @@ import {
|
|||
TextInput,
|
||||
} from 'react-native';
|
||||
import type { ViewProps, TextInputProps } from 'react-native';
|
||||
import network from './network'
|
||||
import type { Options as NetworkOptions } from './network'
|
||||
|
||||
const { ORTrackerConnector } = NativeModules;
|
||||
|
||||
|
|
@ -68,6 +70,14 @@ interface IORTrackerConnector {
|
|||
event: (name: string, payload?: string) => void;
|
||||
setUserID: (userID: string) => void;
|
||||
userAnonymousID: (userID: string) => void;
|
||||
networkRequest: (
|
||||
url: string,
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
||||
requestJSON: string,
|
||||
responseJSON: string,
|
||||
status: number,
|
||||
duration: number
|
||||
) => void;
|
||||
}
|
||||
|
||||
const emptyShell = {
|
||||
|
|
@ -77,13 +87,19 @@ const emptyShell = {
|
|||
event: () => null,
|
||||
setUserID: () => null,
|
||||
userAnonymousID: () => null,
|
||||
networkRequest: () => null,
|
||||
};
|
||||
|
||||
const patchNetwork = (ctx = global, isServiceUrl = () => false, opts: Partial<NetworkOptions>) => {
|
||||
network(ctx, ORTrackerConnector.networkRequest, isServiceUrl, opts)
|
||||
}
|
||||
|
||||
export default {
|
||||
tracker:
|
||||
Platform.OS === 'ios'
|
||||
? (ORTrackerConnector as IORTrackerConnector)
|
||||
: emptyShell,
|
||||
patchNetwork: Platform.OS === 'ios' ? patchNetwork : () => null,
|
||||
ORTouchTrackingView:
|
||||
Platform.OS === 'ios' ? RntrackerTouchTrackingView : View,
|
||||
ORSanitizedView: Platform.OS === 'ios' ? RntrackerSanitizedView : View,
|
||||
|
|
|
|||
71
tracker/tracker-reactnative/src/network.ts
Normal file
71
tracker/tracker-reactnative/src/network.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import setProxy from './Network/index'
|
||||
|
||||
interface RequestData {
|
||||
body: string | null
|
||||
headers: Record<string, string>
|
||||
}
|
||||
|
||||
interface ResponseData {
|
||||
body: any
|
||||
headers: Record<string, string>
|
||||
}
|
||||
|
||||
export interface RequestResponseData {
|
||||
readonly status: number
|
||||
readonly method: string
|
||||
url: string
|
||||
request: RequestData
|
||||
response: ResponseData
|
||||
}
|
||||
|
||||
type Sanitizer = (data: RequestResponseData) => RequestResponseData
|
||||
|
||||
export interface Options {
|
||||
ignoreHeaders: Array<string> | boolean
|
||||
capturePayload: boolean
|
||||
captureInIframes: boolean
|
||||
sanitizer?: Sanitizer
|
||||
tokenUrlMatcher?: (url: string) => boolean
|
||||
}
|
||||
|
||||
export default function (context = global, sendMessage: (args: any[]) => void, isServiceUrl: (url: string) => boolean, opts: Partial<Options> = {}) {
|
||||
const options: Options = Object.assign(
|
||||
{
|
||||
failuresOnly: false,
|
||||
ignoreHeaders: ['cookie', 'set-cookie', 'authorization'],
|
||||
capturePayload: false,
|
||||
captureInIframes: true,
|
||||
axiosInstances: undefined,
|
||||
useProxy: true,
|
||||
},
|
||||
opts,
|
||||
)
|
||||
|
||||
function sanitize(reqResInfo: RequestResponseData) {
|
||||
if (!options.capturePayload) {
|
||||
// @ts-ignore
|
||||
delete reqResInfo.request.body
|
||||
delete reqResInfo.response.body
|
||||
}
|
||||
if (options.sanitizer) {
|
||||
const resBody = reqResInfo.response.body
|
||||
if (typeof resBody === 'string') {
|
||||
// Parse response in order to have handy view in sanitization function
|
||||
try {
|
||||
reqResInfo.response.body = JSON.parse(resBody)
|
||||
} catch {}
|
||||
}
|
||||
return options.sanitizer(reqResInfo)
|
||||
}
|
||||
return reqResInfo
|
||||
}
|
||||
|
||||
return setProxy(
|
||||
context,
|
||||
options.ignoreHeaders,
|
||||
sanitize,
|
||||
sendMessage,
|
||||
(url) => isServiceUrl(url),
|
||||
options.tokenUrlMatcher,
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue