microsoft/vscode-react-native
Publicmirrored fromhttps://github.com/microsoft/vscode-react-nativeAvailable
src/extension/networkInspector/views/inspectorConsoleView.ts
272lines · modecode
| 1 | // Copyright (c) Microsoft Corporation. All rights reserved. |
| 2 | // Licensed under the MIT license. See LICENSE file in the project root for details. |
| 3 | |
| 4 | import { InspectorView } from "./inspectorView"; |
| 5 | import { RequestParams } from "../clientDevice"; |
| 6 | import { URL, URLSearchParams } from "url"; |
| 7 | import * as vscode from "vscode"; |
| 8 | import { |
| 9 | Request, |
| 10 | Response, |
| 11 | Header, |
| 12 | ResponseFollowupChunk, |
| 13 | PartialResponse, |
| 14 | } from "../networkMessageData"; |
| 15 | import { EditorColorThemesHelper, SystemColorTheme } from "../../../common/editorColorThemesHelper"; |
| 16 | import { SettingsHelper } from "../../settingsHelper"; |
| 17 | import { combineBase64Chunks } from "../requestBodyFormatters/utils"; |
| 18 | import { FormattedBody } from "../requestBodyFormatters/requestBodyFormatter"; |
| 19 | import { Base64 } from "js-base64"; |
| 20 | |
| 21 | interface ConsoleNetworkRequestDataView { |
| 22 | title: string; |
| 23 | networkRequestData: { |
| 24 | URL: string; |
| 25 | Method: string; |
| 26 | Status: number; |
| 27 | Duration: string; |
| 28 | "Request Headers": Record<string, string>; |
| 29 | "Response Headers": Record<string, string>; |
| 30 | "Request Body": FormattedBody | null | undefined; |
| 31 | "Request Query Parameters"?: Record<string, string> | undefined; |
| 32 | "Response Body": FormattedBody | null | undefined; |
| 33 | }; |
| 34 | } |
| 35 | |
| 36 | export class InspectorConsoleView extends InspectorView { |
| 37 | private readonly maxResponseBodyLength = 75000; |
| 38 | private readonly openDeveloperToolsCommand = "workbench.action.toggleDevTools"; |
| 39 | private readonly consoleLogsColors = { |
| 40 | Blue: "#0000ff", |
| 41 | Orange: "#f28b54", |
| 42 | }; |
| 43 | |
| 44 | private consoleLogsColor: string; |
| 45 | |
| 46 | public async init(): Promise<void> { |
| 47 | if (!this.isInitialized) { |
| 48 | this.isInitialized = true; |
| 49 | await vscode.commands.executeCommand(this.openDeveloperToolsCommand); |
| 50 | if (EditorColorThemesHelper.isAutoDetectColorSchemeEnabled()) { |
| 51 | this.setupConsoleLogsColor(EditorColorThemesHelper.getCurrentSystemColorTheme()); |
| 52 | } else { |
| 53 | this.setupConsoleLogsColor( |
| 54 | SettingsHelper.getNetworkInspectorConsoleLogsColorTheme(), |
| 55 | ); |
| 56 | } |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | public handleMessage(data: RequestParams): void { |
| 61 | if (data.params) { |
| 62 | switch (data.method) { |
| 63 | case "newRequest": |
| 64 | this.handleRequest(data.params as Request); |
| 65 | break; |
| 66 | case "newResponse": |
| 67 | this.handleResponse(data.params as Response); |
| 68 | break; |
| 69 | case "partialResponse": |
| 70 | this.handlePartialResponse(data.params as Response | ResponseFollowupChunk); |
| 71 | break; |
| 72 | } |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | private setupConsoleLogsColor(systemColorTheme: SystemColorTheme): void { |
| 77 | if (systemColorTheme === SystemColorTheme.Light) { |
| 78 | this.consoleLogsColor = this.consoleLogsColors.Blue; |
| 79 | } else { |
| 80 | this.consoleLogsColor = this.consoleLogsColors.Orange; |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | private handleRequest(data: Request): void { |
| 85 | this.requests.set(data.id, data); |
| 86 | } |
| 87 | |
| 88 | private handleResponse(data: Response): void { |
| 89 | this.responses.set(data.id, data); |
| 90 | if (this.requests.has(data.id)) { |
| 91 | this.printNetworkRequestData( |
| 92 | this.createNetworkRequestData(this.requests.get(data.id) as Request, data), |
| 93 | ); |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | private handlePartialResponse(data: Response | ResponseFollowupChunk): void { |
| 98 | /* Some clients (such as low end Android devices) struggle to serialise large payloads in one go, so partial responses allow them |
| 99 | to split payloads into chunks and serialise each individually. |
| 100 | |
| 101 | Such responses will be distinguished between normal responses by both: |
| 102 | * Being sent to the partialResponse method. |
| 103 | * Having a totalChunks value > 1. |
| 104 | |
| 105 | The first chunk will always be included in the initial response. This response must have index 0. |
| 106 | The remaining chunks will be sent in ResponseFollowupChunks, which each contain another piece of the payload, along with their index from 1 onwards. |
| 107 | The payload of each chunk is individually encoded in the same way that full responses are. |
| 108 | |
| 109 | The order that initialResponse, and followup chunks are recieved is not guaranteed to be in index order. |
| 110 | */ |
| 111 | |
| 112 | let newPartialResponseEntry; |
| 113 | let responseId; |
| 114 | if (data.index !== undefined && data.index > 0) { |
| 115 | // It's a follow up chunk |
| 116 | const followupChunk: ResponseFollowupChunk = data as ResponseFollowupChunk; |
| 117 | const partialResponseEntry = this.partialResponses.get(followupChunk.id) ?? { |
| 118 | followupChunks: {}, |
| 119 | }; |
| 120 | |
| 121 | newPartialResponseEntry = Object.assign({}, partialResponseEntry); |
| 122 | newPartialResponseEntry.followupChunks[followupChunk.index] = followupChunk.data; |
| 123 | responseId = followupChunk.id; |
| 124 | } else { |
| 125 | // It's an initial chunk |
| 126 | const partialResponse: Response = data as Response; |
| 127 | const partialResponseEntry = this.partialResponses.get(partialResponse.id) ?? { |
| 128 | followupChunks: {}, |
| 129 | }; |
| 130 | newPartialResponseEntry = { |
| 131 | ...partialResponseEntry, |
| 132 | initialResponse: partialResponse, |
| 133 | }; |
| 134 | responseId = partialResponse.id; |
| 135 | } |
| 136 | const response = this.assembleChunksIfResponseIsComplete(newPartialResponseEntry); |
| 137 | if (response) { |
| 138 | this.handleResponse(response); |
| 139 | this.partialResponses.delete(responseId); |
| 140 | } else { |
| 141 | this.partialResponses.set(responseId, newPartialResponseEntry); |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | /** |
| 146 | * @preserve |
| 147 | * Start region: the code is borrowed from https://github.com/facebook/flipper/blob/v0.79.1/desktop/plugins/network/index.tsx#L276-L324 |
| 148 | * |
| 149 | * Copyright (c) Facebook, Inc. and its affiliates. |
| 150 | * |
| 151 | * This source code is licensed under the MIT license found in the |
| 152 | * LICENSE file in the root directory of this source tree. |
| 153 | * |
| 154 | * @format |
| 155 | */ |
| 156 | private assembleChunksIfResponseIsComplete( |
| 157 | partialResponseEntry: PartialResponse, |
| 158 | ): Response | null { |
| 159 | const numChunks = partialResponseEntry.initialResponse?.totalChunks; |
| 160 | if ( |
| 161 | !partialResponseEntry.initialResponse || |
| 162 | !numChunks || |
| 163 | Object.keys(partialResponseEntry.followupChunks).length + 1 < numChunks |
| 164 | ) { |
| 165 | // Partial response not yet complete, do nothing. |
| 166 | return null; |
| 167 | } |
| 168 | // Partial response has all required chunks, convert it to a full Response. |
| 169 | |
| 170 | const response: Response = partialResponseEntry.initialResponse; |
| 171 | const allChunks: string[] = |
| 172 | response.data != null |
| 173 | ? [ |
| 174 | response.data, |
| 175 | ...Object.entries(partialResponseEntry.followupChunks) |
| 176 | // It's important to parseInt here or it sorts lexicographically |
| 177 | .sort((a, b) => parseInt(a[0], 10) - parseInt(b[0], 10)) |
| 178 | .map(([_k, v]: [string, string]) => v), |
| 179 | ] |
| 180 | : []; |
| 181 | const data = combineBase64Chunks(allChunks); |
| 182 | |
| 183 | const newResponse = { |
| 184 | ...response, |
| 185 | // Currently data is always decoded at render time, so re-encode it to match the single response format. |
| 186 | data: Base64.btoa(data), |
| 187 | }; |
| 188 | |
| 189 | return newResponse; |
| 190 | } |
| 191 | |
| 192 | /** |
| 193 | * @preserve |
| 194 | * End region: https://github.com/facebook/flipper/blob/v0.79.1/desktop/plugins/network/index.tsx#L276-L324 |
| 195 | */ |
| 196 | |
| 197 | private createNetworkRequestData( |
| 198 | request: Request, |
| 199 | response: Response, |
| 200 | ): ConsoleNetworkRequestDataView { |
| 201 | const url = new URL(request.url); |
| 202 | const networkRequestConsoleView = { |
| 203 | title: `%cNetwork request: ${request.method} ${ |
| 204 | url ? url.host + url.pathname : "<unknown>" |
| 205 | }`, |
| 206 | networkRequestData: { |
| 207 | URL: request.url, |
| 208 | Method: request.method, |
| 209 | Status: response.status, |
| 210 | Duration: this.getRequestDurationString(request.timestamp, response.timestamp), |
| 211 | "Request Headers": this.prepareHeadersViewObject(request.headers), |
| 212 | "Response Headers": this.prepareHeadersViewObject(response.headers), |
| 213 | "Request Body": this.requestBodyDecoder.formatBody(request), |
| 214 | "Response Body": this.requestBodyDecoder.formatBody(response), |
| 215 | }, |
| 216 | }; |
| 217 | |
| 218 | if (url.search) { |
| 219 | networkRequestConsoleView.networkRequestData[ |
| 220 | "Request Query Parameters" |
| 221 | ] = this.retrieveURLSearchParams(url.searchParams); |
| 222 | } |
| 223 | |
| 224 | return networkRequestConsoleView; |
| 225 | } |
| 226 | |
| 227 | private retrieveURLSearchParams(searchParams: URLSearchParams): Record<string, string> { |
| 228 | let formattedSearchParams: Record<string, string> = {}; |
| 229 | searchParams.forEach((value: string, key: string) => { |
| 230 | formattedSearchParams[key] = value; |
| 231 | }); |
| 232 | return formattedSearchParams; |
| 233 | } |
| 234 | |
| 235 | private getRequestDurationString(requestTimestamp: number, responseTimestamp: number): string { |
| 236 | return Math.abs(responseTimestamp - requestTimestamp) + "ms"; |
| 237 | } |
| 238 | |
| 239 | private prepareHeadersViewObject(headers: Header[]): Record<string, string> { |
| 240 | return headers.reduce((headersViewObject, header) => { |
| 241 | headersViewObject[header.key] = header.value; |
| 242 | return headersViewObject; |
| 243 | }, {}); |
| 244 | } |
| 245 | |
| 246 | private printNetworkRequestData(networkRequestData: ConsoleNetworkRequestDataView): void { |
| 247 | const responseBody = networkRequestData.networkRequestData["Response Body"]; |
| 248 | if ( |
| 249 | responseBody && |
| 250 | typeof responseBody === "string" && |
| 251 | responseBody.length > this.maxResponseBodyLength |
| 252 | ) { |
| 253 | networkRequestData.networkRequestData["Response Body"] = |
| 254 | responseBody.substring(0, this.maxResponseBodyLength) + |
| 255 | "... (Response body exceeds output limit, the rest its part is omitted)"; |
| 256 | } |
| 257 | |
| 258 | console.log( |
| 259 | networkRequestData.title, |
| 260 | `color: ${this.consoleLogsColor}`, |
| 261 | networkRequestData.networkRequestData, |
| 262 | ); |
| 263 | |
| 264 | this.logger.debug( |
| 265 | `${networkRequestData.title}\ncolor: ${this.consoleLogsColor}\n${JSON.stringify( |
| 266 | networkRequestData.networkRequestData, |
| 267 | null, |
| 268 | 2, |
| 269 | )}`, |
| 270 | ); |
| 271 | } |
| 272 | } |
| 273 | |