"use strict";
/*
 * Copyright 2023 Google LLC.
 * Copyright (c) Microsoft Corporation.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 *
 */
Object.defineProperty(exports, "__esModule", { value: true });
exports.NetworkRequest = void 0;
const protocol_js_1 = require("../../../protocol/protocol.js");
const assert_js_1 = require("../../../utils/assert.js");
const Deferred_js_1 = require("../../../utils/Deferred.js");
const log_js_1 = require("../../../utils/log.js");
const NetworkUtils_js_1 = require("./NetworkUtils.js");
const REALM_REGEX = /(?<=realm=").*(?=")/;
/** Abstracts one individual network request. */
class NetworkRequest {
    static unknownParameter = 'UNKNOWN';
    /**
     * Each network request has an associated request id, which is a string
     * uniquely identifying that request.
     *
     * The identifier for a request resulting from a redirect matches that of the
     * request that initiated it.
     */
    #id;
    #fetchId;
    /**
     * Indicates the network intercept phase, if the request is currently blocked.
     * Undefined necessarily implies that the request is not blocked.
     */
    #interceptPhase;
    #servedFromCache = false;
    #redirectCount;
    #request = {};
    #response = {};
    #eventManager;
    #networkStorage;
    #cdpTarget;
    #logger;
    #emittedEvents = {
        [protocol_js_1.ChromiumBidi.Network.EventNames.AuthRequired]: false,
        [protocol_js_1.ChromiumBidi.Network.EventNames.BeforeRequestSent]: false,
        [protocol_js_1.ChromiumBidi.Network.EventNames.FetchError]: false,
        [protocol_js_1.ChromiumBidi.Network.EventNames.ResponseCompleted]: false,
        [protocol_js_1.ChromiumBidi.Network.EventNames.ResponseStarted]: false,
    };
    waitNextPhase = new Deferred_js_1.Deferred();
    constructor(id, eventManager, networkStorage, cdpTarget, redirectCount = 0, logger) {
        this.#id = id;
        this.#eventManager = eventManager;
        this.#networkStorage = networkStorage;
        this.#cdpTarget = cdpTarget;
        this.#redirectCount = redirectCount;
        this.#logger = logger;
    }
    get id() {
        return this.#id;
    }
    get fetchId() {
        return this.#fetchId;
    }
    /**
     * When blocked returns the phase for it
     */
    get interceptPhase() {
        return this.#interceptPhase;
    }
    get url() {
        const fragment = this.#request.info?.request.urlFragment ??
            this.#request.paused?.request.urlFragment ??
            '';
        const url = this.#response.info?.url ??
            this.#response.paused?.request.url ??
            this.#request.auth?.request.url ??
            this.#request.info?.request.url ??
            this.#request.paused?.request.url ??
            NetworkRequest.unknownParameter;
        return `${url}${fragment}`;
    }
    get method() {
        return (this.#request.info?.request.method ??
            this.#request.paused?.request.method ??
            this.#request.auth?.request.method ??
            this.#response.paused?.request.method ??
            NetworkRequest.unknownParameter);
    }
    get redirectCount() {
        return this.#redirectCount;
    }
    get cdpTarget() {
        return this.#cdpTarget;
    }
    get cdpClient() {
        return this.#cdpTarget.cdpClient;
    }
    isRedirecting() {
        return Boolean(this.#request.info);
    }
    isDataUrl() {
        return this.url.startsWith('data:');
    }
    #phaseChanged() {
        this.waitNextPhase.resolve();
        this.waitNextPhase = new Deferred_js_1.Deferred();
    }
    #interceptsInPhase(phase) {
        if (!this.#cdpTarget.isSubscribedTo(`network.${phase}`)) {
            return new Set();
        }
        return this.#networkStorage.getInterceptsForPhase(this, phase);
    }
    #isBlockedInPhase(phase) {
        return this.#interceptsInPhase(phase).size > 0;
    }
    handleRedirect(event) {
        // TODO: use event.redirectResponse;
        // Temporary workaround to emit ResponseCompleted event for redirects
        this.#response.hasExtraInfo = false;
        this.#response.info = event.redirectResponse;
        this.#emitEventsIfReady({
            wasRedirected: true,
        });
    }
    #emitEventsIfReady(options = {}) {
        const requestExtraInfoCompleted = 
        // Flush redirects
        options.wasRedirected ||
            options.hasFailed ||
            this.isDataUrl() ||
            Boolean(this.#request.extraInfo) ||
            // Requests from cache don't have extra info
            this.#servedFromCache ||
            // Sometimes there is no extra info and the response
            // is the only place we can find out
            Boolean(this.#response.info && !this.#response.hasExtraInfo);
        const noInterceptionExpected = 
        // We can't intercept data urls from CDP
        this.isDataUrl() ||
            // Cached requests never hit the network
            this.#servedFromCache;
        const requestInterceptionExpected = !noInterceptionExpected &&
            this.#isBlockedInPhase("beforeRequestSent" /* Network.InterceptPhase.BeforeRequestSent */);
        const requestInterceptionCompleted = !requestInterceptionExpected ||
            (requestInterceptionExpected && Boolean(this.#request.paused));
        if (Boolean(this.#request.info) &&
            (requestInterceptionExpected
                ? requestInterceptionCompleted
                : requestExtraInfoCompleted)) {
            this.#emitEvent(this.#getBeforeRequestEvent.bind(this));
        }
        const responseExtraInfoCompleted = Boolean(this.#response.extraInfo) ||
            // Response from cache don't have extra info
            this.#servedFromCache ||
            // Don't expect extra info if the flag is false
            Boolean(this.#response.info && !this.#response.hasExtraInfo);
        const responseInterceptionExpected = !noInterceptionExpected &&
            this.#isBlockedInPhase("responseStarted" /* Network.InterceptPhase.ResponseStarted */);
        if (this.#response.info ||
            (responseInterceptionExpected && Boolean(this.#response.paused))) {
            this.#emitEvent(this.#getResponseStartedEvent.bind(this));
        }
        const responseInterceptionCompleted = !responseInterceptionExpected ||
            (responseInterceptionExpected && Boolean(this.#response.paused));
        if (Boolean(this.#response.info) &&
            responseExtraInfoCompleted &&
            responseInterceptionCompleted) {
            this.#emitEvent(this.#getResponseReceivedEvent.bind(this));
        }
    }
    onRequestWillBeSentEvent(event) {
        this.#request.info = event;
        this.#emitEventsIfReady();
    }
    onRequestWillBeSentExtraInfoEvent(event) {
        this.#request.extraInfo = event;
        this.#emitEventsIfReady();
    }
    onResponseReceivedExtraInfoEvent(event) {
        this.#response.extraInfo = event;
        this.#emitEventsIfReady();
    }
    onResponseReceivedEvent(event) {
        this.#response.hasExtraInfo = event.hasExtraInfo;
        this.#response.info = event.response;
        this.#emitEventsIfReady();
    }
    onServedFromCache() {
        this.#servedFromCache = true;
        this.#emitEventsIfReady();
    }
    onLoadingFailedEvent(event) {
        this.#emitEventsIfReady({
            hasFailed: true,
        });
        this.#emitEvent(() => {
            return {
                method: protocol_js_1.ChromiumBidi.Network.EventNames.FetchError,
                params: {
                    ...this.#getBaseEventParams(),
                    errorText: event.errorText,
                },
            };
        });
    }
    /** @see https://chromedevtools.github.io/devtools-protocol/tot/Fetch/#method-failRequest */
    async failRequest(errorReason) {
        (0, assert_js_1.assert)(this.#fetchId, 'Network Interception not set-up.');
        await this.cdpClient.sendCommand('Fetch.failRequest', {
            requestId: this.#fetchId,
            errorReason,
        });
        this.#interceptPhase = undefined;
    }
    onRequestPaused(event) {
        this.#fetchId = event.requestId;
        // CDP https://chromedevtools.github.io/devtools-protocol/tot/Fetch/#event-requestPaused
        if (event.responseStatusCode || event.responseErrorReason) {
            this.#response.paused = event;
            if (this.#isBlockedInPhase("responseStarted" /* Network.InterceptPhase.ResponseStarted */) &&
                // CDP may emit multiple events for a single request
                !this.#emittedEvents[protocol_js_1.ChromiumBidi.Network.EventNames.ResponseStarted] &&
                // Continue all response that have not enabled Network domain
                this.#fetchId !== this.id) {
                this.#interceptPhase = "responseStarted" /* Network.InterceptPhase.ResponseStarted */;
            }
            else {
                void this.continueResponse();
            }
        }
        else {
            this.#request.paused = event;
            if (this.#isBlockedInPhase("beforeRequestSent" /* Network.InterceptPhase.BeforeRequestSent */) &&
                // CDP may emit multiple events for a single request
                !this.#emittedEvents[protocol_js_1.ChromiumBidi.Network.EventNames.BeforeRequestSent] &&
                // Continue all requests that have not enabled Network domain
                this.#fetchId !== this.id) {
                this.#interceptPhase = "beforeRequestSent" /* Network.InterceptPhase.BeforeRequestSent */;
            }
            else {
                void this.continueRequest();
            }
        }
        this.#emitEventsIfReady();
    }
    onAuthRequired(event) {
        this.#fetchId = event.requestId;
        this.#request.auth = event;
        if (this.#isBlockedInPhase("authRequired" /* Network.InterceptPhase.AuthRequired */) &&
            // Continue all auth requests that have not enabled Network domain
            this.#fetchId !== this.id) {
            this.#interceptPhase = "authRequired" /* Network.InterceptPhase.AuthRequired */;
        }
        else {
            void this.continueWithAuth();
        }
        this.#emitEvent(() => {
            return {
                method: protocol_js_1.ChromiumBidi.Network.EventNames.AuthRequired,
                params: {
                    ...this.#getBaseEventParams("authRequired" /* Network.InterceptPhase.AuthRequired */),
                    response: this.#getResponseEventParams(),
                },
            };
        });
    }
    /** @see https://chromedevtools.github.io/devtools-protocol/tot/Fetch/#method-continueRequest */
    async continueRequest({ url, method, headers, postData, } = {}) {
        (0, assert_js_1.assert)(this.#fetchId, 'Network Interception not set-up.');
        await this.cdpClient.sendCommand('Fetch.continueRequest', {
            requestId: this.#fetchId,
            url,
            method,
            headers,
            postData,
        });
        this.#interceptPhase = undefined;
    }
    /** @see https://chromedevtools.github.io/devtools-protocol/tot/Fetch/#method-continueResponse */
    async continueResponse({ responseCode, responsePhrase, responseHeaders, } = {}) {
        (0, assert_js_1.assert)(this.#fetchId, 'Network Interception not set-up.');
        await this.cdpClient.sendCommand('Fetch.continueResponse', {
            requestId: this.#fetchId,
            responseCode,
            responsePhrase,
            responseHeaders,
        });
        this.#interceptPhase = undefined;
    }
    /** @see https://chromedevtools.github.io/devtools-protocol/tot/Fetch/#method-continueWithAuth */
    async continueWithAuth(authChallengeResponse = {
        response: 'Default',
    }) {
        (0, assert_js_1.assert)(this.#fetchId, 'Network Interception not set-up.');
        await this.cdpClient.sendCommand('Fetch.continueWithAuth', {
            requestId: this.#fetchId,
            authChallengeResponse,
        });
        this.#interceptPhase = undefined;
    }
    /** @see https://chromedevtools.github.io/devtools-protocol/tot/Fetch/#method-provideResponse */
    async provideResponse({ responseCode, responsePhrase, responseHeaders, body, }) {
        (0, assert_js_1.assert)(this.#fetchId, 'Network Interception not set-up.');
        await this.cdpClient.sendCommand('Fetch.fulfillRequest', {
            requestId: this.#fetchId,
            responseCode,
            responsePhrase,
            responseHeaders,
            body,
        });
        this.#interceptPhase = undefined;
    }
    get #context() {
        return (this.#response.paused?.frameId ??
            this.#request.info?.frameId ??
            this.#request.paused?.frameId ??
            this.#request.auth?.frameId ??
            null);
    }
    /** Returns the HTTP status code associated with this request if any. */
    get statusCode() {
        return (this.#response.paused?.responseStatusCode ??
            this.#response.extraInfo?.statusCode ??
            this.#response.info?.status);
    }
    #emitEvent(getEvent) {
        let event;
        try {
            event = getEvent();
        }
        catch (error) {
            this.#logger?.(log_js_1.LogType.debugError, error);
            return;
        }
        if (this.#isIgnoredEvent() ||
            (this.#emittedEvents[event.method] &&
                // Special case this event can be emitted multiple times
                event.method !== protocol_js_1.ChromiumBidi.Network.EventNames.AuthRequired)) {
            return;
        }
        this.#phaseChanged();
        this.#emittedEvents[event.method] = true;
        this.#eventManager.registerEvent(Object.assign(event, {
            type: 'event',
        }), this.#context);
    }
    #getBaseEventParams(phase) {
        const interceptProps = {
            isBlocked: false,
        };
        if (phase) {
            const blockedBy = this.#interceptsInPhase(phase);
            interceptProps.isBlocked = blockedBy.size > 0;
            if (interceptProps.isBlocked) {
                interceptProps.intercepts = [...blockedBy];
            }
        }
        return {
            context: this.#context,
            navigation: this.#getNavigationId(),
            redirectCount: this.#redirectCount,
            request: this.#getRequestData(),
            // Timestamp should be in milliseconds, while CDP provides it in seconds.
            timestamp: Math.round((this.#request.info?.wallTime ?? 0) * 1000),
            // Contains isBlocked and intercepts
            ...interceptProps,
        };
    }
    #getResponseEventParams() {
        // Chromium sends wrong extraInfo events for responses served from cache.
        // See https://github.com/puppeteer/puppeteer/issues/9965 and
        // https://crbug.com/1340398.
        if (this.#response.info?.fromDiskCache) {
            this.#response.extraInfo = undefined;
        }
        // TODO: get headers from Fetch.requestPaused
        const headers = (0, NetworkUtils_js_1.bidiNetworkHeadersFromCdpNetworkHeaders)(this.#response.info?.headers);
        // TODO: get headers from Fetch.requestPaused
        const authChallenges = this.#authChallenges(this.#response.info?.headers ?? {});
        return {
            url: this.url,
            protocol: this.#response.info?.protocol ?? '',
            status: this.statusCode ?? -1, // TODO: Throw an exception or use some other status code?
            statusText: this.#response.info?.statusText ||
                this.#response.paused?.responseStatusText ||
                '',
            fromCache: this.#response.info?.fromDiskCache ||
                this.#response.info?.fromPrefetchCache ||
                this.#servedFromCache,
            headers,
            mimeType: this.#response.info?.mimeType || '',
            bytesReceived: this.#response.info?.encodedDataLength || 0,
            headersSize: (0, NetworkUtils_js_1.computeHeadersSize)(headers),
            // TODO: consider removing from spec.
            bodySize: 0,
            content: {
                // TODO: consider removing from spec.
                size: 0,
            },
            ...(authChallenges ? { authChallenges } : {}),
        };
    }
    #getNavigationId() {
        if (!this.#request.info ||
            !this.#request.info.loaderId ||
            // When we navigate all CDP network events have `loaderId`
            // CDP's `loaderId` and `requestId` match when
            // that request triggered the loading
            this.#request.info.loaderId !== this.#request.info.requestId) {
            return null;
        }
        return this.#request.info.loaderId;
    }
    #getRequestData() {
        const cookies = this.#request.extraInfo
            ? NetworkRequest.#getCookies(this.#request.extraInfo.associatedCookies)
            : [];
        const headers = (0, NetworkUtils_js_1.bidiNetworkHeadersFromCdpNetworkHeaders)(this.#request.info?.request.headers);
        return {
            request: this.#id,
            url: this.url,
            method: this.method,
            headers,
            cookies,
            headersSize: (0, NetworkUtils_js_1.computeHeadersSize)(headers),
            // TODO: implement.
            bodySize: 0,
            timings: this.#getTimings(),
        };
    }
    // TODO: implement.
    #getTimings() {
        return {
            timeOrigin: 0,
            requestTime: 0,
            redirectStart: 0,
            redirectEnd: 0,
            fetchStart: 0,
            dnsStart: 0,
            dnsEnd: 0,
            connectStart: 0,
            connectEnd: 0,
            tlsStart: 0,
            requestStart: 0,
            responseStart: 0,
            responseEnd: 0,
        };
    }
    #getBeforeRequestEvent() {
        (0, assert_js_1.assert)(this.#request.info, 'RequestWillBeSentEvent is not set');
        return {
            method: protocol_js_1.ChromiumBidi.Network.EventNames.BeforeRequestSent,
            params: {
                ...this.#getBaseEventParams("beforeRequestSent" /* Network.InterceptPhase.BeforeRequestSent */),
                initiator: {
                    type: NetworkRequest.#getInitiatorType(this.#request.info.initiator.type),
                    columnNumber: this.#request.info.initiator.columnNumber,
                    lineNumber: this.#request.info.initiator.lineNumber,
                    stackTrace: this.#request.info.initiator.stack,
                    request: this.#request.info.initiator.requestId,
                },
            },
        };
    }
    #getResponseStartedEvent() {
        (0, assert_js_1.assert)(this.#request.info, 'RequestWillBeSentEvent is not set');
        (0, assert_js_1.assert)(
        // The response paused comes before any data for the response
        this.#response.paused || this.#response.info, 'ResponseReceivedEvent is not set');
        return {
            method: protocol_js_1.ChromiumBidi.Network.EventNames.ResponseStarted,
            params: {
                ...this.#getBaseEventParams("responseStarted" /* Network.InterceptPhase.ResponseStarted */),
                response: this.#getResponseEventParams(),
            },
        };
    }
    #getResponseReceivedEvent() {
        (0, assert_js_1.assert)(this.#request.info, 'RequestWillBeSentEvent is not set');
        (0, assert_js_1.assert)(this.#response.info, 'ResponseReceivedEvent is not set');
        return {
            method: protocol_js_1.ChromiumBidi.Network.EventNames.ResponseCompleted,
            params: {
                ...this.#getBaseEventParams(),
                response: this.#getResponseEventParams(),
            },
        };
    }
    #isIgnoredEvent() {
        const faviconUrl = '/favicon.ico';
        return (this.#request.paused?.request.url.endsWith(faviconUrl) ??
            this.#request.info?.request.url.endsWith(faviconUrl) ??
            false);
    }
    #authChallenges(headers) {
        if (!(this.statusCode === 401 || this.statusCode === 407)) {
            return undefined;
        }
        const headerName = this.statusCode === 401 ? 'WWW-Authenticate' : 'Proxy-Authenticate';
        const authChallenges = [];
        for (const [header, value] of Object.entries(headers)) {
            // TODO: Do a proper match based on https://httpwg.org/specs/rfc9110.html#credentials
            // Or verify this works
            if (header.localeCompare(headerName, undefined, { sensitivity: 'base' }) === 0) {
                authChallenges.push({
                    scheme: value.split(' ').at(0) ?? '',
                    realm: value.match(REALM_REGEX)?.at(0) ?? '',
                });
            }
        }
        return authChallenges;
    }
    static #getInitiatorType(initiatorType) {
        switch (initiatorType) {
            case 'parser':
            case 'script':
            case 'preflight':
                return initiatorType;
            default:
                return 'other';
        }
    }
    static #getCookies(associatedCookies) {
        return associatedCookies
            .filter(({ blockedReasons }) => {
            return !Array.isArray(blockedReasons) || blockedReasons.length === 0;
        })
            .map(({ cookie }) => (0, NetworkUtils_js_1.cdpToBiDiCookie)(cookie));
    }
}
exports.NetworkRequest = NetworkRequest;
//# sourceMappingURL=NetworkRequest.js.map