UNPKG

chrome-devtools-frontend

Version:
261 lines (232 loc) • 9.21 kB
// Copyright 2023 The Chromium Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import * as Common from '../../core/common/common.js'; import * as Platform from '../../core/platform/platform.js'; import {contentAsDataURL, type DeferredContent} from './ContentProvider.js'; import {Text} from './Text.js'; const objectUrlRegistry = new FinalizationRegistry<string>(url => { URL.revokeObjectURL(url); }); /** * The maximum size in bytes that we are willing to convert to a Blob. * 10MB is chosen as a safe upper limit to prevent freezing the DevTools UI * when synchronously decoding large Base64 strings. */ const MAX_BLOB_SIZE_BYTES = 10 * 1024 * 1024; /** * This class is a small wrapper around either raw binary or text data. * As the binary data can actually contain textual data, we also store the * MIME type and if applicable, the charset. * * This information should be generally kept together, as interpreting text * from raw bytes requires an encoding. * * Note that we only rarely have to decode text ourselves in the frontend, * this is mostly handled by the backend. There are cases though (e.g. SVG, * or streaming response content) where we receive text data in * binary (base64-encoded) form. * * The class only implements decoding. We currently don't have a use-case * to re-encode text into base64 bytes using a specified charset. */ export class ContentData { readonly mimeType: string; readonly charset: string; #contentAsBase64?: string; #contentAsText?: string; #contentAsTextObj?: Text; #imagePreviewUrl?: string; constructor(data: string, isBase64: boolean, mimeType: string, charset?: string) { this.charset = charset || 'utf-8'; if (isBase64) { this.#contentAsBase64 = data; } else { this.#contentAsText = data; } this.mimeType = mimeType; if (!this.mimeType) { // Tests or broken requests might pass an empty/undefined mime type. Fallback to // "default" mime types. this.mimeType = isBase64 ? 'application/octet-stream' : 'text/plain'; } } /** * Returns the data as base64. * * @throws if this `ContentData` was constructed from text content. */ get base64(): string { if (this.#contentAsBase64 === undefined) { throw new Error('Encoding text content as base64 is not supported'); } return this.#contentAsBase64; } /** * Returns the content as text. If this `ContentData` was constructed with base64 * encoded bytes, it will use the provided charset to attempt to decode the bytes. * * @throws if `mimeType` is not a text type. */ get text(): string { if (this.#contentAsText !== undefined) { return this.#contentAsText; } if (!this.isTextContent) { throw new Error('Cannot interpret binary data as text'); } const bytes = Common.Base64.decode(this.#contentAsBase64 as string); this.#contentAsText = new TextDecoder(this.charset).decode(bytes); return this.#contentAsText; } /** @returns true, if this `ContentData` was constructed from text content or the mime type indicates text that can be decoded */ get isTextContent(): boolean { return this.#createdFromText || Platform.MimeType.isTextType(this.mimeType); } get isEmpty(): boolean { // Don't trigger unnecessary decoding. Only check if both of the strings are empty. return !Boolean(this.#contentAsBase64) && !Boolean(this.#contentAsText); } get createdFromBase64(): boolean { return this.#contentAsBase64 !== undefined; } get #createdFromText(): boolean { return this.#contentAsBase64 === undefined; } /** * Returns the text content as a `Text` object. The returned object is always the same to * minimize the number of times we have to calculate the line endings array. * * @throws if `mimeType` is not a text type. */ get textObj(): Text { if (this.#contentAsTextObj === undefined) { this.#contentAsTextObj = new Text(this.text); } return this.#contentAsTextObj; } /** * @returns True, iff the contents (base64 or text) are equal. * Does not compare mime type and charset, but will decode base64 data if both * mime types indicate that it's text content. */ contentEqualTo(other: ContentData): boolean { if (this.#contentAsBase64 !== undefined && other.#contentAsBase64 !== undefined) { return this.#contentAsBase64 === other.#contentAsBase64; } if (this.#contentAsText !== undefined && other.#contentAsText !== undefined) { return this.#contentAsText === other.#contentAsText; } if (this.isTextContent && other.isTextContent) { return this.text === other.text; } return false; } asDataUrl(): string|null { // To keep with existing behavior we prefer to return the content // encoded if that is how this ContentData was constructed with. if (this.#contentAsBase64 !== undefined) { const charset = this.isTextContent ? this.charset : null; return contentAsDataURL(this.#contentAsBase64, this.mimeType ?? '', true, charset); } return contentAsDataURL(this.text, this.mimeType ?? '', false, 'utf-8'); } /** * Returns the content as a Blob. * * We prefer Base64 as the source for the Blob because it represents the raw binary * bytes. Converting binary data (like an image) to a UTF-16 string (contentAsText) * is destructive and will corrupt the image data. * * @returns The Blob representation, or `null` if the content exceeds the 10MB safety limit. */ asBlob(): Blob|null { // We prefer Base64 as the source for the Blob because it represents the raw binary // bytes. Converting binary data (like an image) to a UTF-16 string (contentAsText) // is destructive and will corrupt the image data. if (this.#contentAsBase64 !== undefined) { // Base64 encoding uses 4 characters to represent 3 bytes of data. // Therefore, the byte size is approximately 75% of the string length. if (this.#contentAsBase64.length * 0.75 > MAX_BLOB_SIZE_BYTES) { return null; } const bytes = Common.Base64.decode(this.#contentAsBase64); return new Blob([bytes], {type: this.mimeType}); } const text = this.#contentAsText ?? ''; if (text.length > MAX_BLOB_SIZE_BYTES) { return null; } return new Blob([text], {type: this.mimeType}); } /** * Gets the image as either a data: or blob: URL. * * We prefer data: for simplicity, but these are limited in size to 1MB. If * the resource is >1MB, we fall back to a blob: URL. * * @returns An object URL, or `null` if the content exceeds the 10MB safety limit. */ asImagePreviewUrl(): string|null { if (this.#imagePreviewUrl) { return this.#imagePreviewUrl; } const url = this.asDataUrl(); if (url !== null) { this.#imagePreviewUrl = url; return this.#imagePreviewUrl; } const blob = this.asBlob(); if (blob === null) { return null; } this.#imagePreviewUrl = URL.createObjectURL(blob); objectUrlRegistry.register(this, this.#imagePreviewUrl); return this.#imagePreviewUrl; } /** * @deprecated Used during migration from `DeferredContent` to `ContentData`. */ asDeferedContent(): DeferredContent { // To prevent encoding mistakes, we'll return text content already decoded. if (this.isTextContent) { return {content: this.text, isEncoded: false}; } if (this.#contentAsText !== undefined) { // Unknown text mime type, this should not really happen. return {content: this.#contentAsText, isEncoded: false}; } if (this.#contentAsBase64 !== undefined) { return {content: this.#contentAsBase64, isEncoded: true}; } throw new Error('Unreachable'); } static isError(contentDataOrError: ContentDataOrError): contentDataOrError is {error: string} { return 'error' in contentDataOrError; } /** @returns `value` if the passed `ContentDataOrError` is an error, or the text content otherwise */ static textOr<T>(contentDataOrError: ContentDataOrError, value: T): string|T { if (ContentData.isError(contentDataOrError)) { return value; } return contentDataOrError.text; } /** @returns an empty 'text/plain' content data if the passed `ContentDataOrError` is an error, or the content data itself otherwise */ static contentDataOrEmpty(contentDataOrError: ContentDataOrError): ContentData { if (ContentData.isError(contentDataOrError)) { return EMPTY_TEXT_CONTENT_DATA; } return contentDataOrError; } /** * @deprecated Used during migration from `DeferredContent` to `ContentData`. */ static asDeferredContent(contentDataOrError: ContentDataOrError): DeferredContent { if (ContentData.isError(contentDataOrError)) { return {error: contentDataOrError.error, content: null, isEncoded: false}; } return contentDataOrError.asDeferedContent(); } } export const EMPTY_TEXT_CONTENT_DATA = new ContentData('', /* isBase64 */ false, 'text/plain'); export type ContentDataOrError = ContentData|{error: string};