chrome-devtools-frontend
Version:
Chrome DevTools UI
261 lines (232 loc) • 9.21 kB
text/typescript
// 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};