xref: /MusicFree/src/lib/react-native-vdebug/src/network.js (revision 5589cdf32b2bb0f641e5ac7bf1f6152cd6b9b70e)
1import React, {Component} from 'react';
2import {Clipboard, FlatList, StyleSheet, Text, TextInput, TouchableOpacity, View,} from 'react-native';
3import event from './event';
4import {debounce} from './tool';
5
6let ajaxStack = null;
7
8class AjaxStack {
9    constructor() {
10        this.requestIds = [];
11        this.requests = {};
12        this.maxLength = 200;
13        this.listeners = [];
14        this.notify = debounce(10, false, this.notify);
15    }
16
17    getRequestIds() {
18        return this.requestIds;
19    }
20
21    getRequests() {
22        return this.requests;
23    }
24
25    getRequest(id) {
26        return this.requests[id] || {};
27    }
28
29    readBlobAsText(blob, encoding = 'utf-8') {
30        return new Promise((resolve, reject) => {
31            resolve('undefined');
32            // const fr = new FileReader();
33            // fr.onload = event => {
34            //   resolve(fr.result);
35            // };
36            // fr.onerror = err => {
37            //   reject(err);
38            // };
39            // fr.readAsText(blob, encoding);
40        });
41    }
42
43    JSONTryParse(jsonStr) {
44        try {
45            return JSON.parse(jsonStr);
46        } catch (error) {
47            return {};
48        }
49    }
50
51    formatResponse(response) {
52        if (response) {
53            if (typeof response === 'string')
54                response = this.JSONTryParse(response);
55            return JSON.stringify(response, null, 2);
56        } else {
57            return '{}';
58        }
59    }
60
61    updateRequest(id, data) {
62        // update item
63        const item = this.requests[id] || {};
64
65        if (this.requestIds.length > this.maxLength) {
66            const _id = this.requestIds[this.requestIds.length - 1];
67            this.requestIds.splice(this.requestIds.length - 1, 1);
68            this.requests[id] && delete this.requests[_id];
69        }
70        for (const key in data) {
71            item[key] = data[key];
72        }
73        // update dom
74        const domData = {
75            id,
76            index: item.index ?? this.requestIds.length + 1,
77            host: item.host,
78            url: item.url,
79            status: item.status,
80            method: item.method || '-',
81            costTime: item.costTime > 0 ? `${item.costTime} ms` : '-',
82            resHeaders: item.resHeaders || null,
83            reqHeaders: item.reqHeaders || null,
84            getData: item.getData || null,
85            postData: item.postData || null,
86            response: null,
87            actived: !!item.actived,
88            startTime: item.startTime,
89            endTime: item.endTime,
90        };
91        switch (item.responseType) {
92            case '':
93            case 'text':
94                // try to parse JSON
95                if (typeof item.response === 'string') {
96                    try {
97                        domData.response = this.formatResponse(item.response);
98                    } catch (e) {
99                        // not a JSON string
100                        domData.response = item.response;
101                    }
102                } else if (typeof item.response !== 'undefined') {
103                    domData.response = Object.prototype.toString.call(
104                        item.response,
105                    );
106                }
107                break;
108            case 'json':
109                if (typeof item.response !== 'undefined') {
110                    domData.response = this.formatResponse(item.response);
111                }
112                break;
113            case 'blob':
114            case 'document':
115            case 'arraybuffer':
116            default:
117                if (item.response && typeof item.response !== 'undefined') {
118                    this.readBlobAsText(item.response).then(res => {
119                        domData.response = this.formatResponse(res);
120                    });
121                }
122                break;
123        }
124        if (item.readyState === 0 || item.readyState === 1) {
125            domData.status = 'Pending';
126        } else if (item.readyState === 2 || item.readyState === 3) {
127            domData.status = 'Loading';
128        } else if (item.readyState === 4) {
129            // do nothing
130        } else {
131            domData.status = 'Unknown';
132        }
133        if (this.requestIds.indexOf(id) === -1) {
134            this.requestIds.splice(0, 0, id);
135        }
136        this.requests[id] = domData;
137        this.notify(this.requests[id]);
138    }
139
140    clearRequests() {
141        this.requestIds = [];
142        this.requests = {};
143        this.notify();
144    }
145
146    notify(args) {
147        this.listeners.forEach(callback => {
148            callback(args);
149        });
150    }
151
152    attach(callback) {
153        this.listeners.push(callback);
154    }
155}
156
157class Network extends Component {
158    constructor(props) {
159        super(props);
160        this.name = 'Network';
161        this.mountState = false;
162        this.state = {
163            showingId: null,
164            requestIds: [],
165            requests: {},
166            filterValue: '',
167        };
168        ajaxStack.attach(currentRequest => {
169            if (this.mountState) {
170                this.setState({
171                    requestIds: ajaxStack.getRequestIds(),
172                    requests: ajaxStack.getRequests(),
173                });
174            }
175        });
176    }
177
178    getScrollRef() {
179        return this.flatList;
180    }
181
182    componentDidMount() {
183        this.mountState = true;
184        this.setState({
185            requestIds: ajaxStack.getRequestIds(),
186            requests: ajaxStack.getRequests(),
187        });
188        event.on('clear', this.clearRequests.bind(this));
189    }
190
191    componentWillUnmount() {
192        this.mountState = false;
193        event.off('clear', this.clearRequests.bind(this));
194    }
195
196    clearRequests(name) {
197        if (name === this.name) {
198            ajaxStack.clearRequests();
199        }
200    }
201
202    ListHeaderComponent() {
203        const count = Object.keys(this.state.requests).length || 0;
204        return (
205            <View>
206                <View style={[styles.nwHeader]}>
207                    <Text
208                        style={[
209                            styles.nwHeaderTitle,
210                            styles.flex3,
211                            styles.bold,
212                        ]}>
213                        ({count})Host
214                    </Text>
215                    <Text
216                        style={[
217                            styles.nwHeaderTitle,
218                            styles.flex1,
219                            styles.bold,
220                        ]}>
221                        Method
222                    </Text>
223                    <Text
224                        style={[
225                            styles.nwHeaderTitle,
226                            styles.flex1,
227                            styles.bold,
228                        ]}>
229                        Status
230                    </Text>
231                    <Text
232                        style={[
233                            styles.nwHeaderTitle,
234                            styles.bold,
235                            {width: 90},
236                        ]}>
237                        Time/Retry
238                    </Text>
239                </View>
240                <View style={styles.filterValueBar}>
241                    <TextInput
242                        ref={ref => {
243                            this.textInput = ref;
244                        }}
245                        style={styles.filterValueBarInput}
246                        placeholderTextColor={'#000000a1'}
247                        placeholder="输入过滤条件..."
248                        onSubmitEditing={({nativeEvent}) => {
249                            if (nativeEvent) {
250                                this.regInstance = new RegExp(
251                                    nativeEvent.text,
252                                    'ig',
253                                );
254                                this.setState({filterValue: nativeEvent.text});
255                            }
256                        }}
257                    />
258                    <TouchableOpacity
259                        style={styles.filterValueBarBtn}
260                        onPress={this.clearFilterValue.bind(this)}>
261                        <Text>X</Text>
262                    </TouchableOpacity>
263                </View>
264            </View>
265        );
266    }
267
268    clearFilterValue() {
269        this.setState(
270            {
271                filterValue: '',
272            },
273            () => {
274                this.textInput.clear();
275            },
276        );
277    }
278
279    copy2cURL(item) {
280        let headerStr = '';
281        if (item.reqHeaders) {
282            Object.keys(item.reqHeaders).forEach(key => {
283                let reqHeaders = item.reqHeaders[key];
284                if (reqHeaders) {
285                    headerStr += ` -H '${key}: ${reqHeaders}'`;
286                }
287            });
288        }
289        let cURL = `curl -X ${item.method} '${item.url}' ${headerStr}`;
290        if (item.method === 'POST' && item.postData)
291            cURL += ` --data-binary '${item.postData}'`;
292        Clipboard.setString(cURL);
293    }
294
295    retryFetch(item) {
296        let options = {
297            method: item.method,
298        };
299        if (item.reqHeaders) options.headers = item.reqHeaders;
300        if (item.method == 'POST' && item.postData)
301            options.body = item.postData;
302        fetch(item.url, options);
303    }
304
305    renderItem({item}) {
306        const _item = this.state.requests[item] || {};
307        if (
308            this.state.filterValue &&
309            this.regInstance &&
310            !this.regInstance.test(_item.url)
311        )
312            return null;
313        return (
314            <View style={styles.nwItem}>
315                <TouchableOpacity
316                    onPress={() => {
317                        this.setState(state => ({
318                            showingId:
319                                state.showingId === _item.id ? null : _item.id,
320                        }));
321                    }}>
322                    <View
323                        style={[
324                            styles.nwHeader,
325                            this.state.showingId === _item.id && styles.active,
326                            _item.status >= 400 && styles.error,
327                        ]}>
328                        <Text
329                            numberOfLines={1}
330                            ellipsizeMode="middle"
331                            style={[styles.nwHeaderTitle, styles.flex3]}>
332                            {`(${_item.index})${_item.host}`}
333                        </Text>
334                        <Text style={[styles.nwHeaderTitle, styles.flex1]}>
335                            {_item.method}
336                        </Text>
337                        <Text
338                            numberOfLines={1}
339                            style={[styles.nwHeaderTitle, styles.flex1]}>
340                            {_item.status}
341                        </Text>
342                        <TouchableOpacity
343                            onPress={() => {
344                                this.retryFetch(_item);
345                            }}
346                            style={[
347                                styles.nwHeaderTitle,
348                                {
349                                    width: 90,
350                                    borderRadius: 20,
351                                    borderColor: '#eeeeee',
352                                    borderWidth: 1,
353                                },
354                            ]}>
355                            <Text>{_item.costTime}</Text>
356                        </TouchableOpacity>
357                    </View>
358                </TouchableOpacity>
359                {this.state.showingId === _item.id && (
360                    <View style={styles.nwItemDetail}>
361                        <View>
362                            <Text
363                                style={[
364                                    styles.nwItemDetailHeader,
365                                    styles.bold,
366                                ]}>
367                                Operate
368                            </Text>
369                            <TouchableOpacity
370                                onPress={() => {
371                                    this.copy2cURL(_item);
372                                }}>
373                                <Text>{'[ Copy cURL to clipboard ]'}</Text>
374                            </TouchableOpacity>
375                            <TouchableOpacity
376                                onPress={() => {
377                                    Clipboard.setString(_item.response);
378                                }}>
379                                <Text>{'[ Copy response to clipboard ]'}</Text>
380                            </TouchableOpacity>
381                        </View>
382                        <View>
383                            <Text
384                                style={[
385                                    styles.nwItemDetailHeader,
386                                    styles.bold,
387                                ]}>
388                                General
389                            </Text>
390                            <View style={styles.nwDetailItem}>
391                                <Text>URL:</Text>
392                                <Text>{_item.url}</Text>
393                            </View>
394                            <View style={styles.nwDetailItem}>
395                                <Text>startTime:</Text>
396                                <Text>{_item.startTime}</Text>
397                            </View>
398                            <View style={styles.nwDetailItem}>
399                                <Text>endTime:</Text>
400                                <Text>{_item.endTime}</Text>
401                            </View>
402                        </View>
403                        {_item.reqHeaders && (
404                            <View>
405                                <Text
406                                    style={[
407                                        styles.nwItemDetailHeader,
408                                        styles.bold,
409                                    ]}>
410                                    Request Header
411                                </Text>
412                                {Object.keys(_item.reqHeaders).map(key => (
413                                    <View style={styles.nwDetailItem} key={key}>
414                                        <Text>{key}:</Text>
415                                        <Text>{_item.reqHeaders[key]}</Text>
416                                    </View>
417                                ))}
418                            </View>
419                        )}
420                        {_item.resHeaders && (
421                            <View>
422                                <Text
423                                    style={[
424                                        styles.nwItemDetailHeader,
425                                        styles.bold,
426                                    ]}>
427                                    Response Header
428                                </Text>
429                                {Object.keys(_item.resHeaders).map(key => (
430                                    <View style={styles.nwDetailItem} key={key}>
431                                        <Text>{key}:</Text>
432                                        <Text>{_item.resHeaders[key]}</Text>
433                                    </View>
434                                ))}
435                            </View>
436                        )}
437                        {_item.getData && (
438                            <View>
439                                <Text
440                                    style={[
441                                        styles.nwItemDetailHeader,
442                                        styles.bold,
443                                    ]}>
444                                    Query String Parameters
445                                </Text>
446                                {Object.keys(_item.getData).map(key => (
447                                    <View style={styles.nwDetailItem} key={key}>
448                                        <Text>{key}:</Text>
449                                        <Text>{_item.getData[key]}</Text>
450                                    </View>
451                                ))}
452                            </View>
453                        )}
454                        {_item.postData && (
455                            <View>
456                                <Text
457                                    style={[
458                                        styles.nwItemDetailHeader,
459                                        styles.bold,
460                                    ]}>
461                                    Form Data
462                                </Text>
463                                <Text>{_item.postData}</Text>
464                            </View>
465                        )}
466                        <View>
467                            <Text
468                                style={[
469                                    styles.nwItemDetailHeader,
470                                    styles.bold,
471                                ]}>
472                                Response
473                            </Text>
474                            <View style={[styles.nwDetailItem]}>
475                                <Text>{_item.response || ''}</Text>
476                            </View>
477                        </View>
478                    </View>
479                )}
480            </View>
481        );
482    }
483
484    render() {
485        return (
486            <FlatList
487                ref={ref => {
488                    this.flatList = ref;
489                }}
490                showsVerticalScrollIndicator={true}
491                ListHeaderComponent={this.ListHeaderComponent.bind(this)}
492                extraData={this.state}
493                data={this.state.requestIds}
494                stickyHeaderIndices={[0]}
495                renderItem={this.renderItem.bind(this)}
496                ListEmptyComponent={() => <Text> Loading...</Text>}
497                keyExtractor={item => item}
498            />
499        );
500    }
501}
502
503const styles = StyleSheet.create({
504    bold: {
505        fontWeight: '700',
506    },
507    active: {
508        backgroundColor: '#fffacd',
509    },
510    flex3: {
511        flex: 3,
512    },
513    flex1: {
514        flex: 1,
515    },
516    error: {
517        backgroundColor: '#ffe4e1',
518        borderColor: '#ffb930',
519    },
520    nwHeader: {
521        flexDirection: 'row',
522        backgroundColor: '#fff',
523    },
524    nwHeaderTitle: {
525        borderColor: '#eee',
526        borderWidth: StyleSheet.hairlineWidth,
527        paddingVertical: 4,
528        paddingHorizontal: 2,
529    },
530    nwItem: {},
531    nwItemDetail: {
532        borderColor: '#eee',
533        borderLeftWidth: StyleSheet.hairlineWidth,
534    },
535    nwItemDetailHeader: {
536        paddingLeft: 5,
537        paddingVertical: 4,
538        backgroundColor: '#eee',
539    },
540    nwDetailItem: {
541        paddingLeft: 5,
542        flexDirection: 'row',
543    },
544    filterValueBarBtn: {
545        width: 40,
546        alignItems: 'center',
547        justifyContent: 'center',
548        backgroundColor: '#eee',
549    },
550    filterValueBarInput: {
551        flex: 1,
552        paddingLeft: 10,
553        backgroundColor: '#ffffff',
554        color: '#000000',
555    },
556    filterValueBar: {
557        flexDirection: 'row',
558        height: 40,
559        borderWidth: 1,
560        borderColor: '#eee',
561    },
562});
563
564function unixId() {
565    return Math.round(Math.random() * 1000000).toString(16);
566}
567
568function proxyAjax(XHR, stack) {
569    if (!XHR) {
570        return;
571    }
572    const _open = XHR.prototype.open;
573    const _send = XHR.prototype.send;
574    this._open = _open;
575    this._send = _send;
576
577    // mock open()
578    XHR.prototype.open = function (...args) {
579        const XMLReq = this;
580        const method = args[0];
581        const url = args[1];
582        const id = unixId();
583        let timer = null;
584
585        // may be used by other functions
586        XMLReq._requestID = id;
587        XMLReq._method = method;
588        XMLReq._url = url;
589
590        // mock onreadystatechange
591        const _onreadystatechange = XMLReq.onreadystatechange || function () {};
592        const onreadystatechange = function () {
593            const item = stack.getRequest(id);
594
595            // update status
596            item.readyState = XMLReq.readyState;
597            item.status = 0;
598            if (XMLReq.readyState > 1) {
599                item.status = XMLReq.status;
600            }
601            item.responseType = XMLReq.responseType;
602
603            if (XMLReq.readyState === 0) {
604                // UNSENT
605                if (!item.startTime) {
606                    item.startTime = +new Date();
607                }
608            } else if (XMLReq.readyState === 1) {
609                // OPENED
610                if (!item.startTime) {
611                    item.startTime = +new Date();
612                }
613            } else if (XMLReq.readyState === 2) {
614                // HEADERS_RECEIVED
615                item.resHeaders = {};
616                const resHeaders = XMLReq.getAllResponseHeaders() || '';
617                const resHeadersArr = resHeaders.split('\n');
618                // extract plain text to key-value format
619                for (let i = 0; i < resHeadersArr.length; i++) {
620                    const line = resHeadersArr[i];
621                    if (!line) {
622                        // eslint-disable-next-line no-continue
623                        continue;
624                    }
625                    const arr = line.split(': ');
626                    const key = arr[0];
627                    const value = arr.slice(1).join(': ');
628                    item.resHeaders[key] = value;
629                }
630            } else if (XMLReq.readyState === 3) {
631                // LOADING
632            } else if (XMLReq.readyState === 4) {
633                // DONE
634                clearInterval(timer);
635                item.endTime = +new Date();
636                item.costTime = item.endTime - (item.startTime || item.endTime);
637                item.response = XMLReq.response;
638            } else {
639                clearInterval(timer);
640            }
641
642            if (!XMLReq._noVConsole) {
643                stack.updateRequest(id, item);
644            }
645            return _onreadystatechange.apply(XMLReq, args);
646        };
647        XMLReq.onreadystatechange = onreadystatechange;
648
649        // some 3rd libraries will change XHR's default function
650        // so we use a timer to avoid lost tracking of readyState
651        let preState = -1;
652        timer = setInterval(() => {
653            if (preState !== XMLReq.readyState) {
654                preState = XMLReq.readyState;
655                onreadystatechange.call(XMLReq);
656            }
657        }, 10);
658
659        return _open.apply(XMLReq, args);
660    };
661
662    // mock send()
663    XHR.prototype.send = function (...args) {
664        const XMLReq = this;
665        const data = args[0];
666
667        const item = stack.getRequest(XMLReq._requestID);
668        item.method = XMLReq._method.toUpperCase();
669
670        let query = XMLReq._url.split('?'); // a.php?b=c&d=?e => ['a.php', 'b=c&d=', '?e']
671        item.url = XMLReq._url;
672        item.host = query[0];
673
674        if (query.length == 2) {
675            item.getData = {};
676            query = query[1].split('&'); // => ['b=c', 'd=?e']
677            for (let q of query) {
678                q = q.split('=');
679                item.getData[q[0]] = decodeURIComponent(q[1]);
680            }
681        }
682
683        item.reqHeaders = XMLReq._headers;
684
685        if (item.method === 'POST' && data) {
686            // save POST data
687            if (typeof data === 'string') {
688                item.postData = data;
689            } else {
690                try {
691                    item.postData = JSON.stringify(data);
692                } catch (error) {}
693            }
694        }
695
696        if (!XMLReq._noVConsole) {
697            stack.updateRequest(XMLReq._requestID, item);
698        }
699
700        return _send.apply(XMLReq, args);
701    };
702}
703
704export default Network;
705
706export const traceNetwork = () => {
707    if (!ajaxStack) {
708        ajaxStack = new AjaxStack();
709        proxyAjax(
710            global.originalXMLHttpRequest || global.XMLHttpRequest,
711            ajaxStack,
712        );
713    }
714};
715