playwright/src/network.ts

458 lines
13 KiB
TypeScript
Raw Normal View History

2020-01-06 18:22:35 -08:00
/**
* 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.
*/
2020-04-01 14:42:47 -07:00
import * as fs from 'fs';
import * as mime from 'mime';
import * as util from 'util';
import * as frames from './frames';
import { assert, helper } from './helper';
2020-05-28 03:43:49 +02:00
import { URLSearchParams } from 'url';
export type NetworkCookie = {
name: string,
value: string,
domain: string,
path: string,
expires: number,
httpOnly: boolean,
secure: boolean,
sameSite: 'Strict' | 'Lax' | 'None'
};
export type SetNetworkCookieParam = {
name: string,
value: string,
url?: string,
domain?: string,
path?: string,
expires?: number,
httpOnly?: boolean,
secure?: boolean,
sameSite?: 'Strict' | 'Lax' | 'None'
};
export function filterCookies(cookies: NetworkCookie[], urls: string | string[] = []): NetworkCookie[] {
if (!Array.isArray(urls))
urls = [ urls ];
const parsedURLs = urls.map(s => new URL(s));
// Chromiums's cookies are missing sameSite when it is 'None'
return cookies.filter(c => {
if (!parsedURLs.length)
return true;
for (const parsedURL of parsedURLs) {
if (parsedURL.hostname !== c.domain)
continue;
if (!parsedURL.pathname.startsWith(c.path))
continue;
if ((parsedURL.protocol === 'https:') !== c.secure)
continue;
return true;
}
return false;
});
}
export function rewriteCookies(cookies: SetNetworkCookieParam[]): SetNetworkCookieParam[] {
return cookies.map(c => {
assert(c.name, 'Cookie should have a name');
assert(c.value, 'Cookie should have a value');
assert(c.url || (c.domain && c.path), 'Cookie should have a url or a domain/path pair');
assert(!(c.url && c.domain), 'Cookie should have either url or domain');
assert(!(c.url && c.path), 'Cookie should have either url or domain');
const copy = {...c};
if (copy.url) {
assert(copy.url !== 'about:blank', `Blank page can not have cookie "${c.name}"`);
assert(!copy.url.startsWith('data:'), `Data URL page can not have cookie "${c.name}"`);
const url = new URL(copy.url);
copy.domain = url.hostname;
copy.path = url.pathname.substring(0, url.pathname.lastIndexOf('/') + 1);
copy.secure = url.protocol === 'https:';
}
return copy;
});
}
function stripFragmentFromUrl(url: string): string {
if (!url.indexOf('#'))
return url;
const parsed = new URL(url);
parsed.hash = '';
return parsed.href;
}
export type Headers = { [key: string]: string };
export class Request {
readonly _routeDelegate: RouteDelegate | null;
private _response: Response | null = null;
private _redirectedFrom: Request | null;
private _redirectedTo: Request | null = null;
readonly _documentId?: string;
readonly _isFavicon: boolean;
private _failureText: string | null = null;
private _url: string;
private _resourceType: string;
private _method: string;
private _postData: string | null;
private _headers: Headers;
private _frame: frames.Frame;
private _waitForResponsePromise: Promise<Response | null>;
private _waitForResponsePromiseCallback: (value: Response | null) => void = () => {};
constructor(routeDelegate: RouteDelegate | null, frame: frames.Frame, redirectedFrom: Request | null, documentId: string | undefined,
url: string, resourceType: string, method: string, postData: string | null, headers: Headers) {
assert(!url.startsWith('data:'), 'Data urls should not fire requests');
this._routeDelegate = routeDelegate;
this._frame = frame;
this._redirectedFrom = redirectedFrom;
if (redirectedFrom)
redirectedFrom._redirectedTo = this;
this._documentId = documentId;
this._url = stripFragmentFromUrl(url);
this._resourceType = resourceType;
this._method = method;
this._postData = postData;
this._headers = headers;
this._waitForResponsePromise = new Promise(f => this._waitForResponsePromiseCallback = f);
this._isFavicon = url.endsWith('/favicon.ico');
}
_setFailureText(failureText: string) {
this._failureText = failureText;
this._waitForResponsePromiseCallback(null);
}
url(): string {
return this._url;
}
resourceType(): string {
return this._resourceType;
}
method(): string {
return this._method;
}
postData(): string | null {
return this._postData;
}
2020-05-28 03:43:49 +02:00
postDataJSON(): Object | null {
if (!this._postData)
return null;
const contentType = this.headers()['content-type'];
if (!contentType)
return null;
if (contentType === 'application/x-www-form-urlencoded') {
const entries: Record<string, string> = {};
const parsed = new URLSearchParams(this._postData);
for (const [k, v] of parsed.entries())
entries[k] = v;
return entries;
}
return JSON.parse(this._postData);
}
headers(): {[key: string]: string} {
return { ...this._headers };
}
response(): Promise<Response | null> {
return this._waitForResponsePromise;
}
_existingResponse(): Response | null {
return this._response;
}
_setResponse(response: Response) {
this._response = response;
this._waitForResponsePromiseCallback(response);
}
_finalRequest(): Request {
return this._redirectedTo ? this._redirectedTo._finalRequest() : this;
}
frame(): frames.Frame {
return this._frame;
}
isNavigationRequest(): boolean {
return !!this._documentId;
}
redirectedFrom(): Request | null {
return this._redirectedFrom;
}
redirectedTo(): Request | null {
return this._redirectedTo;
}
failure(): { errorText: string; } | null {
if (this._failureText === null)
return null;
return {
errorText: this._failureText
};
}
_route(): Route | null {
if (!this._routeDelegate)
return null;
return new Route(this, this._routeDelegate);
}
}
export class Route {
private readonly _request: Request;
private readonly _delegate: RouteDelegate;
private _handled = false;
constructor(request: Request, delegate: RouteDelegate) {
this._request = request;
this._delegate = delegate;
}
request(): Request {
return this._request;
}
async abort(errorCode: string = 'failed') {
assert(!this._handled, 'Route is already handled!');
this._handled = true;
await this._delegate.abort(errorCode);
}
async fulfill(response: FulfillResponse & { path?: string }) {
assert(!this._handled, 'Route is already handled!');
this._handled = true;
if (response.path) {
response = {
status: response.status,
headers: response.headers,
2020-04-01 14:42:47 -07:00
contentType: mime.getType(response.path) || 'application/octet-stream',
body: await util.promisify(fs.readFile)(response.path)
};
}
await this._delegate.fulfill(response);
}
async continue(overrides: { method?: string; headers?: Headers; postData?: string } = {}) {
assert(!this._handled, 'Route is already handled!');
await this._delegate.continue(overrides);
}
}
export type RouteHandler = (route: Route, request: Request) => void;
2020-04-01 14:42:47 -07:00
type GetResponseBodyCallback = () => Promise<Buffer>;
export class Response {
private _request: Request;
2020-04-01 14:42:47 -07:00
private _contentPromise: Promise<Buffer> | null = null;
_finishedPromise: Promise<Error | null>;
private _finishedPromiseCallback: any;
private _status: number;
private _statusText: string;
private _url: string;
private _headers: Headers;
private _getResponseBodyCallback: GetResponseBodyCallback;
constructor(request: Request, status: number, statusText: string, headers: Headers, getResponseBodyCallback: GetResponseBodyCallback) {
this._request = request;
this._status = status;
this._statusText = statusText;
this._url = request.url();
this._headers = headers;
this._getResponseBodyCallback = getResponseBodyCallback;
this._finishedPromise = new Promise(f => {
this._finishedPromiseCallback = f;
});
this._request._setResponse(this);
}
_requestFinished(error?: Error) {
this._finishedPromiseCallback.call(null, error);
}
url(): string {
return this._url;
}
ok(): boolean {
return this._status === 0 || (this._status >= 200 && this._status <= 299);
}
status(): number {
return this._status;
}
statusText(): string {
return this._statusText;
}
headers(): object {
return { ...this._headers };
}
finished(): Promise<Error | null> {
return this._finishedPromise;
}
2020-04-01 14:42:47 -07:00
body(): Promise<Buffer> {
if (!this._contentPromise) {
this._contentPromise = this._finishedPromise.then(async error => {
if (error)
throw error;
return this._getResponseBodyCallback();
});
}
return this._contentPromise;
}
async text(): Promise<string> {
const content = await this.body();
return content.toString('utf8');
}
async json(): Promise<object> {
const content = await this.text();
return JSON.parse(content);
}
request(): Request {
return this._request;
}
frame(): frames.Frame {
return this._request.frame();
}
}
export type FulfillResponse = {
status?: number,
headers?: Headers,
contentType?: string,
2020-04-01 14:42:47 -07:00
body?: string | Buffer,
};
export interface RouteDelegate {
abort(errorCode: string): Promise<void>;
fulfill(response: FulfillResponse): Promise<void>;
continue(overrides: { method?: string; headers?: Headers; postData?: string; }): Promise<void>;
}
// List taken from https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml with extra 306 and 418 codes.
export const STATUS_TEXTS: { [status: string]: string } = {
'100': 'Continue',
'101': 'Switching Protocols',
'102': 'Processing',
'103': 'Early Hints',
'200': 'OK',
'201': 'Created',
'202': 'Accepted',
'203': 'Non-Authoritative Information',
'204': 'No Content',
'205': 'Reset Content',
'206': 'Partial Content',
'207': 'Multi-Status',
'208': 'Already Reported',
'226': 'IM Used',
'300': 'Multiple Choices',
'301': 'Moved Permanently',
'302': 'Found',
'303': 'See Other',
'304': 'Not Modified',
'305': 'Use Proxy',
'306': 'Switch Proxy',
'307': 'Temporary Redirect',
'308': 'Permanent Redirect',
'400': 'Bad Request',
'401': 'Unauthorized',
'402': 'Payment Required',
'403': 'Forbidden',
'404': 'Not Found',
'405': 'Method Not Allowed',
'406': 'Not Acceptable',
'407': 'Proxy Authentication Required',
'408': 'Request Timeout',
'409': 'Conflict',
'410': 'Gone',
'411': 'Length Required',
'412': 'Precondition Failed',
'413': 'Payload Too Large',
'414': 'URI Too Long',
'415': 'Unsupported Media Type',
'416': 'Range Not Satisfiable',
'417': 'Expectation Failed',
'418': 'I\'m a teapot',
'421': 'Misdirected Request',
'422': 'Unprocessable Entity',
'423': 'Locked',
'424': 'Failed Dependency',
'425': 'Too Early',
'426': 'Upgrade Required',
'428': 'Precondition Required',
'429': 'Too Many Requests',
'431': 'Request Header Fields Too Large',
'451': 'Unavailable For Legal Reasons',
'500': 'Internal Server Error',
'501': 'Not Implemented',
'502': 'Bad Gateway',
'503': 'Service Unavailable',
'504': 'Gateway Timeout',
'505': 'HTTP Version Not Supported',
'506': 'Variant Also Negotiates',
'507': 'Insufficient Storage',
'508': 'Loop Detected',
'510': 'Not Extended',
'511': 'Network Authentication Required',
};
export function verifyHeaders(headers: Headers): Headers {
const result: Headers = {};
for (const key of Object.keys(headers)) {
const value = headers[key];
assert(helper.isString(value), `Expected value of header "${key}" to be String, but "${typeof value}" is found.`);
result[key] = value;
}
return result;
}
export function mergeHeaders(headers: (Headers | undefined | null)[]): Headers {
const lowerCaseToValue = new Map<string, string>();
const lowerCaseToOriginalCase = new Map<string, string>();
for (const h of headers) {
if (!h)
continue;
for (const key of Object.keys(h)) {
const lower = key.toLowerCase();
lowerCaseToOriginalCase.set(lower, key);
lowerCaseToValue.set(lower, h[key]);
}
}
const result: Headers = {};
for (const [lower, value] of lowerCaseToValue)
result[lowerCaseToOriginalCase.get(lower)!] = value;
return result;
}