| /* |
| * Copyright (C) 2012 Google Inc. All rights reserved. |
| * |
| * Redistribution and use in source and binary forms, with or without |
| * modification, are permitted provided that the following conditions are |
| * met: |
| * |
| * * Redistributions of source code must retain the above copyright |
| * notice, this list of conditions and the following disclaimer. |
| * * Redistributions in binary form must reproduce the above |
| * copyright notice, this list of conditions and the following disclaimer |
| * in the documentation and/or other materials provided with the |
| * distribution. |
| * * Neither the name of Google Inc. nor the names of its |
| * contributors may be used to endorse or promote products derived from |
| * this software without specific prior written permission. |
| * |
| * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS |
| * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT |
| * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR |
| * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT |
| * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, |
| * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT |
| * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, |
| * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY |
| * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT |
| * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE |
| * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
| */ |
| |
| // See http://www.softwareishard.com/blog/har-12-spec/ |
| // for HAR specification. |
| |
| // FIXME: Some fields are not yet supported due to back-end limitations. |
| // See https://bugs.webkit.org/show_bug.cgi?id=58127 for details. |
| |
| import * as Common from '../../core/common/common.js'; |
| import type * as Platform from '../../core/platform/platform.js'; |
| import * as SDK from '../../core/sdk/sdk.js'; |
| import type * as Protocol from '../../generated/protocol.js'; |
| |
| export interface BuildOptions { |
| sanitize: boolean; |
| } |
| |
| export class Log { |
| static pseudoWallTime(request: SDK.NetworkRequest.NetworkRequest, monotonicTime: number): Date { |
| return new Date(request.pseudoWallTime(monotonicTime) * 1000); |
| } |
| |
| static async build(requests: SDK.NetworkRequest.NetworkRequest[], options: BuildOptions): Promise<LogDTO> { |
| const log = new Log(); |
| const entryPromises = []; |
| for (const request of requests) { |
| entryPromises.push(Entry.build(request, options)); |
| } |
| const entries = await Promise.all(entryPromises); |
| return {version: '1.2', creator: log.creator(), pages: log.buildPages(requests), entries}; |
| } |
| |
| private creator(): Creator { |
| const webKitVersion = /AppleWebKit\/([^ ]+)/.exec(window.navigator.userAgent); |
| |
| return {name: 'WebInspector', version: webKitVersion ? webKitVersion[1] : 'n/a'}; |
| } |
| |
| private buildPages(requests: SDK.NetworkRequest.NetworkRequest[]): Page[] { |
| const seenIdentifiers = new Set<number>(); |
| const pages = []; |
| for (let i = 0; i < requests.length; ++i) { |
| const request = requests[i]; |
| const page = SDK.PageLoad.PageLoad.forRequest(request); |
| if (!page || seenIdentifiers.has(page.id)) { |
| continue; |
| } |
| seenIdentifiers.add(page.id); |
| pages.push(this.convertPage(page, request)); |
| } |
| return pages; |
| } |
| |
| private convertPage(page: SDK.PageLoad.PageLoad, request: SDK.NetworkRequest.NetworkRequest): Page { |
| return { |
| startedDateTime: Log.pseudoWallTime(request, page.startTime).toJSON(), |
| id: 'page_' + page.id, |
| title: page.url, |
| pageTimings: { |
| onContentLoad: this.pageEventTime(page, page.contentLoadTime), |
| onLoad: this.pageEventTime(page, page.loadTime), |
| }, |
| }; |
| } |
| |
| private pageEventTime(page: SDK.PageLoad.PageLoad, time: number): number { |
| const startTime = page.startTime; |
| if (time === -1 || startTime === -1) { |
| return -1; |
| } |
| return Entry.toMilliseconds(time - startTime); |
| } |
| } |
| |
| export class Entry { |
| private request: SDK.NetworkRequest.NetworkRequest; |
| constructor(request: SDK.NetworkRequest.NetworkRequest) { |
| this.request = request; |
| } |
| |
| static toMilliseconds(time: number): number { |
| return time === -1 ? -1 : time * 1000; |
| } |
| |
| static async build(request: SDK.NetworkRequest.NetworkRequest, options: BuildOptions): Promise<EntryDTO> { |
| const harEntry = new Entry(request); |
| let ipAddress = harEntry.request.remoteAddress(); |
| const portPositionInString = ipAddress.lastIndexOf(':'); |
| const connection = portPositionInString !== -1 ? ipAddress.substring(portPositionInString + 1) : undefined; |
| if (portPositionInString !== -1) { |
| ipAddress = ipAddress.substr(0, portPositionInString); |
| } |
| const timings = harEntry.buildTimings(); |
| let time = 0; |
| // "ssl" is included in the connect field, so do not double count it. |
| for (const t of [timings.blocked, timings.dns, timings.connect, timings.send, timings.wait, timings.receive]) { |
| time += Math.max(t, 0); |
| } |
| |
| const initiator = harEntry.request.initiator(); |
| let exportedInitiator: Protocol.Network.Initiator|null = null; |
| if (initiator) { |
| exportedInitiator = { |
| type: initiator.type, |
| }; |
| if (initiator.url !== undefined) { |
| exportedInitiator.url = initiator.url; |
| } |
| if (initiator.requestId !== undefined) { |
| exportedInitiator.requestId = initiator.requestId; |
| } |
| if (initiator.lineNumber !== undefined) { |
| exportedInitiator.lineNumber = initiator.lineNumber; |
| } |
| if (initiator.stack) { |
| exportedInitiator.stack = initiator.stack; |
| } |
| } |
| |
| const entry: EntryDTO = { |
| _connectionId: undefined, |
| _fromCache: undefined, |
| _initiator: exportedInitiator, |
| _priority: harEntry.request.priority(), |
| _resourceType: harEntry.request.resourceType().name(), |
| _webSocketMessages: undefined, |
| cache: {}, |
| connection, |
| pageref: undefined, |
| request: await harEntry.buildRequest(), |
| response: harEntry.buildResponse(), |
| // IPv6 address should not have square brackets per (https://tools.ietf.org/html/rfc2373#section-2.2). |
| serverIPAddress: ipAddress.replace(/\[\]/g, ''), |
| startedDateTime: Log.pseudoWallTime(harEntry.request, harEntry.request.issueTime()).toJSON(), |
| time, |
| timings, |
| }; |
| |
| // Sanitize HAR to remove sensitive data. |
| |
| if (options.sanitize) { |
| entry.response.cookies = []; |
| entry.response.headers = |
| entry.response.headers.filter(({name}) => !['set-cookie'].includes(name.toLocaleLowerCase())); |
| entry.request.cookies = []; |
| entry.request.headers = |
| entry.request.headers.filter(({name}) => !['authorization', 'cookie'].includes(name.toLocaleLowerCase())); |
| } |
| |
| // Chrome specific. |
| |
| if (harEntry.request.cached()) { |
| entry._fromCache = harEntry.request.cachedInMemory() ? 'memory' : 'disk'; |
| } else { |
| delete entry._fromCache; |
| } |
| |
| if (harEntry.request.connectionId !== '0') { |
| entry._connectionId = harEntry.request.connectionId; |
| } else { |
| delete entry._connectionId; |
| } |
| |
| const page = SDK.PageLoad.PageLoad.forRequest(harEntry.request); |
| if (page) { |
| entry.pageref = 'page_' + page.id; |
| } else { |
| delete entry.pageref; |
| } |
| |
| if (harEntry.request.resourceType() === Common.ResourceType.resourceTypes.WebSocket) { |
| const messages = []; |
| for (const message of harEntry.request.frames()) { |
| messages.push({type: message.type, time: message.time, opcode: message.opCode, data: message.text}); |
| } |
| entry._webSocketMessages = messages; |
| } else { |
| delete entry._webSocketMessages; |
| } |
| |
| return entry; |
| } |
| |
| private async buildRequest(): Promise<Request> { |
| const headersText = this.request.requestHeadersText(); |
| const res: Request = { |
| method: this.request.requestMethod, |
| url: this.buildRequestURL(this.request.url()), |
| httpVersion: this.request.requestHttpVersion(), |
| headers: this.request.requestHeaders(), |
| queryString: this.buildParameters(this.request.queryParameters || []), |
| cookies: this.buildCookies( |
| this.request.includedRequestCookies().map(includedRequestCookie => includedRequestCookie.cookie)), |
| headersSize: headersText ? headersText.length : -1, |
| bodySize: await this.requestBodySize(), |
| postData: undefined, |
| }; |
| const postData = await this.buildPostData(); |
| if (postData) { |
| res.postData = postData; |
| } else { |
| delete res.postData; |
| } |
| |
| return res; |
| } |
| |
| private buildResponse(): Response { |
| const headersText = this.request.responseHeadersText; |
| |
| return { |
| status: this.request.statusCode, |
| statusText: this.request.statusText, |
| httpVersion: this.request.responseHttpVersion(), |
| headers: this.request.responseHeaders, |
| cookies: this.buildCookies(this.request.responseCookies), |
| content: this.buildContent(), |
| redirectURL: this.request.responseHeaderValue('Location') || '', |
| headersSize: headersText ? headersText.length : -1, |
| bodySize: this.responseBodySize, |
| _transferSize: this.request.transferSize, |
| _error: this.request.localizedFailDescription, |
| _fetchedViaServiceWorker: this.request.fetchedViaServiceWorker, |
| _responseCacheStorageCacheName: this.request.getResponseCacheStorageCacheName(), |
| _serviceWorkerResponseSource: this.request.serviceWorkerResponseSource(), |
| _serviceWorkerRouterRuleIdMatched: this.request.serviceWorkerRouterInfo?.ruleIdMatched ?? undefined, |
| _serviceWorkerRouterMatchedSourceType: this.request.serviceWorkerRouterInfo?.matchedSourceType ?? undefined, |
| _serviceWorkerRouterActualSourceType: this.request.serviceWorkerRouterInfo?.actualSourceType ?? undefined, |
| }; |
| } |
| |
| private buildContent(): Content { |
| const content = ({ |
| size: this.request.resourceSize, |
| mimeType: this.request.mimeType || 'x-unknown', |
| compression: undefined, |
| } as Content); |
| const compression = this.responseCompression; |
| if (typeof compression === 'number') { |
| content.compression = compression; |
| } else { |
| delete content.compression; |
| } |
| return content; |
| } |
| |
| private buildTimings(): Timing { |
| // Order of events: request_start = 0, [proxy], [dns], [connect [ssl]], [send], duration |
| const timing = this.request.timing; |
| const issueTime = this.request.issueTime(); |
| const startTime = this.request.startTime; |
| |
| const result: Timing = { |
| blocked: -1, |
| dns: -1, |
| ssl: -1, |
| connect: -1, |
| send: 0, |
| wait: 0, |
| receive: 0, |
| _blocked_queueing: -1, |
| _blocked_proxy: undefined, |
| }; |
| |
| const queuedTime = (issueTime < startTime) ? startTime - issueTime : -1; |
| result.blocked = Entry.toMilliseconds(queuedTime); |
| result._blocked_queueing = Entry.toMilliseconds(queuedTime); |
| |
| let highestTime = 0; |
| if (timing) { |
| // "blocked" here represents both queued + blocked/stalled + proxy (ie: anything before request was started). |
| // We pick the better of when the network request start was reported and pref timing. |
| const blockedStart = leastNonNegative([timing.dnsStart, timing.connectStart, timing.sendStart]); |
| if (blockedStart !== Infinity) { |
| result.blocked += blockedStart; |
| } |
| |
| // Proxy is part of blocked but sometimes (like quic) blocked is -1 but has proxy timings. |
| if (timing.proxyEnd !== -1) { |
| result._blocked_proxy = timing.proxyEnd - timing.proxyStart; |
| } |
| if (result._blocked_proxy && result._blocked_proxy > result.blocked) { |
| result.blocked = result._blocked_proxy; |
| } |
| |
| const dnsStart = timing.dnsEnd >= 0 ? blockedStart : 0; |
| const dnsEnd = timing.dnsEnd >= 0 ? timing.dnsEnd : -1; |
| result.dns = dnsEnd - dnsStart; |
| |
| // SSL timing is included in connection timing. |
| const sslStart = timing.sslEnd > 0 ? timing.sslStart : 0; |
| const sslEnd = timing.sslEnd > 0 ? timing.sslEnd : -1; |
| result.ssl = sslEnd - sslStart; |
| |
| const connectStart = timing.connectEnd >= 0 ? leastNonNegative([dnsEnd, blockedStart]) : 0; |
| const connectEnd = timing.connectEnd >= 0 ? timing.connectEnd : -1; |
| result.connect = connectEnd - connectStart; |
| |
| // Send should not be -1 for legacy reasons even if it is served from cache. |
| const sendStart = timing.sendEnd >= 0 ? Math.max(connectEnd, dnsEnd, blockedStart) : 0; |
| const sendEnd = timing.sendEnd >= 0 ? timing.sendEnd : 0; |
| result.send = sendEnd - sendStart; |
| // Quic sometimes says that sendStart is before connectionEnd (see: crbug.com/740792) |
| if (result.send < 0) { |
| result.send = 0; |
| } |
| highestTime = Math.max(sendEnd, connectEnd, sslEnd, dnsEnd, blockedStart, 0); |
| |
| // Custom fields for service worker timings. |
| result._workerStart = timing.workerStart; |
| result._workerReady = timing.workerReady; |
| result._workerFetchStart = timing.workerFetchStart; |
| result._workerRespondWithSettled = timing.workerRespondWithSettled; |
| result._workerRouterEvaluationStart = timing.workerRouterEvaluationStart; |
| result._workerCacheLookupStart = timing.workerCacheLookupStart; |
| } else if (this.request.responseReceivedTime === -1) { |
| // Means that we don't have any more details after blocked, so attribute all to blocked. |
| result.blocked = Entry.toMilliseconds(this.request.endTime - issueTime); |
| return result; |
| } |
| |
| const requestTime = timing ? timing.requestTime : startTime; |
| const waitStart = highestTime; |
| const waitEnd = Entry.toMilliseconds(this.request.responseReceivedTime - requestTime); |
| result.wait = waitEnd - waitStart; |
| |
| const receiveStart = waitEnd; |
| const receiveEnd = Entry.toMilliseconds(this.request.endTime - requestTime); |
| result.receive = Math.max(receiveEnd - receiveStart, 0); |
| |
| return result; |
| |
| function leastNonNegative(values: number[]): number { |
| return values.reduce((best, value) => (value >= 0 && value < best) ? value : best, Infinity); |
| } |
| } |
| |
| private async buildPostData(): Promise<PostData|null> { |
| const postData = await this.request.requestFormData(); |
| if (!postData) { |
| return null; |
| } |
| const res: PostData = {mimeType: this.request.requestContentType() || '', text: postData, params: undefined}; |
| const formParameters = await this.request.formParameters(); |
| if (formParameters) { |
| res.params = this.buildParameters(formParameters); |
| } else { |
| delete res.params; |
| } |
| return res; |
| } |
| |
| private buildParameters(parameters: Parameter[]): Parameter[] { |
| return parameters.slice(); |
| } |
| |
| private buildRequestURL(url: Platform.DevToolsPath.UrlString): Platform.DevToolsPath.UrlString { |
| return Common.ParsedURL.ParsedURL.split(url, '#', 2)[0]; |
| } |
| |
| private buildCookies(cookies: SDK.Cookie.Cookie[]): CookieDTO[] { |
| return cookies.map(this.buildCookie.bind(this)); |
| } |
| |
| private buildCookie(cookie: SDK.Cookie.Cookie): CookieDTO { |
| const c: CookieDTO = { |
| name: cookie.name(), |
| value: cookie.value(), |
| path: cookie.path(), |
| domain: cookie.domain(), |
| expires: cookie.expiresDate(Log.pseudoWallTime(this.request, this.request.startTime)), |
| httpOnly: cookie.httpOnly(), |
| secure: cookie.secure(), |
| sameSite: undefined, |
| partitionKey: undefined, |
| }; |
| if (cookie.sameSite()) { |
| c.sameSite = cookie.sameSite(); |
| } else { |
| delete c.sameSite; |
| } |
| if (cookie.partitionKey()) { |
| c.partitionKey = cookie.partitionKey(); |
| } else { |
| delete c.partitionKey; |
| } |
| return c; |
| } |
| |
| private async requestBodySize(): Promise<number> { |
| const postData = await this.request.requestFormData(); |
| if (!postData) { |
| return 0; |
| } |
| |
| // As per the har spec, returns the length in bytes of the posted data. |
| // TODO(jarhar): This will be wrong if the underlying encoding is not UTF-8. SDK.NetworkRequest.NetworkRequest.requestFormData is |
| // assumed to be UTF-8 because the backend decodes post data to a UTF-8 string regardless of the provided |
| // content-type/charset in InspectorNetworkAgent::FormDataToString |
| return new TextEncoder().encode(postData).length; |
| } |
| |
| get responseBodySize(): number { |
| if (this.request.cached() || this.request.statusCode === 304) { |
| return 0; |
| } |
| if (!this.request.responseHeadersText) { |
| return -1; |
| } |
| return this.request.transferSize - this.request.responseHeadersText.length; |
| } |
| |
| get responseCompression(): number|undefined { |
| if (this.request.cached() || this.request.statusCode === 304 || this.request.statusCode === 206) { |
| return; |
| } |
| if (!this.request.responseHeadersText) { |
| return; |
| } |
| return this.request.resourceSize - this.responseBodySize; |
| } |
| } |
| |
| export interface Timing { |
| blocked: number; |
| dns: number; |
| ssl: number; |
| connect: number; |
| send: number; |
| wait: number; |
| receive: number; |
| // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration |
| // eslint-disable-next-line @typescript-eslint/naming-convention |
| _blocked_queueing: number; |
| // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration |
| // eslint-disable-next-line @typescript-eslint/naming-convention |
| _blocked_proxy?: number; |
| |
| // Custom fields for service workers. |
| _workerStart?: number; |
| _workerReady?: number; |
| _workerFetchStart?: number; |
| _workerRespondWithSettled?: number; |
| _workerRouterEvaluationStart?: number; |
| _workerCacheLookupStart?: number; |
| } |
| |
| export interface Parameter { |
| name: string; |
| value: string; |
| } |
| |
| export interface Content { |
| size: number; |
| mimeType: string; |
| compression?: number; |
| text?: string; |
| encoding?: string; |
| } |
| |
| export interface Request { |
| method: string; |
| url: Platform.DevToolsPath.UrlString; |
| httpVersion: string; |
| headers: Array<{name: string, value: string, comment?: string}>; |
| queryString: Parameter[]; |
| cookies: CookieDTO[]; |
| headersSize: number; |
| bodySize: number; |
| postData?: PostData; |
| } |
| |
| export interface Response { |
| status: number; |
| statusText: string; |
| httpVersion: string; |
| headers: Array<{name: string, value: string, comment?: string}>; |
| cookies: CookieDTO[]; |
| content: Content; |
| redirectURL: string; |
| headersSize: number; |
| bodySize: number; |
| _transferSize: number; |
| _error: string|null; |
| _fetchedViaServiceWorker: boolean; |
| _responseCacheStorageCacheName: string|undefined; |
| _serviceWorkerResponseSource: Protocol.Network.ServiceWorkerResponseSource|undefined; |
| _serviceWorkerRouterRuleIdMatched: number|undefined; |
| _serviceWorkerRouterMatchedSourceType: string|undefined; |
| _serviceWorkerRouterActualSourceType: string|undefined; |
| } |
| |
| export interface EntryDTO { |
| _connectionId?: string; |
| _fromCache?: string; |
| _initiator: Protocol.Network.Initiator|null; |
| _priority: Protocol.Network.ResourcePriority|null; |
| _resourceType: string; |
| _webSocketMessages?: Object[]; |
| cache: Object; |
| connection?: string; |
| pageref?: string; |
| request: Request; |
| response: Response; |
| serverIPAddress: string; |
| startedDateTime: string|Object; |
| time: number; |
| timings: Timing; |
| } |
| |
| export interface PostData { |
| mimeType: string; |
| params?: Parameter[]; |
| text: string; |
| } |
| |
| export interface CookieDTO { |
| name: string; |
| value: string; |
| path: string; |
| domain: string; |
| expires: Date|null; |
| httpOnly: boolean; |
| secure: boolean; |
| sameSite?: Protocol.Network.CookieSameSite; |
| partitionKey?: Protocol.Network.CookiePartitionKey; |
| } |
| |
| export interface Page { |
| startedDateTime: string|Object; |
| id: string; |
| title: string; |
| pageTimings: { |
| onContentLoad: number, |
| onLoad: number, |
| }; |
| } |
| |
| export interface Creator { |
| version: string; |
| name: string; |
| } |
| |
| export interface LogDTO { |
| version: string; |
| creator: Creator; |
| pages: Page[]; |
| entries: EntryDTO[]; |
| } |