diff --git a/common/api-review/storage.api.md b/common/api-review/storage.api.md index ad83a83eede..78dd0de472b 100644 --- a/common/api-review/storage.api.md +++ b/common/api-review/storage.api.md @@ -58,7 +58,7 @@ export class _FirebaseStorageImpl implements FirebaseStorage { constructor( app: FirebaseApp, _authProvider: Provider, _appCheckProvider: Provider, - _pool: ConnectionPool, _url?: string | undefined, _firebaseVersion?: string | undefined); + _url?: string | undefined, _firebaseVersion?: string | undefined); readonly app: FirebaseApp; // (undocumented) readonly _appCheckProvider: Provider; @@ -78,12 +78,13 @@ export class _FirebaseStorageImpl implements FirebaseStorage { get host(): string; set host(host: string); // Warning: (ae-forgotten-export) The symbol "RequestInfo" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "Connection" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "Request" needs to be exported by the entry point index.d.ts // // (undocumented) - _makeRequest(requestInfo: RequestInfo_2, authToken: string | null, appCheckToken: string | null): Request_2; + _makeRequest(requestInfo: RequestInfo_2, requestFactory: () => Connection, authToken: string | null, appCheckToken: string | null): Request_2; // (undocumented) - makeRequestWithTokens(requestInfo: RequestInfo_2): Promise>; + makeRequestWithTokens(requestInfo: RequestInfo_2, requestFactory: () => Connection): Promise; _makeStorageReference(loc: _Location): _Reference; get maxOperationRetryTime(): number; set maxOperationRetryTime(time: number); @@ -91,10 +92,6 @@ export class _FirebaseStorageImpl implements FirebaseStorage { set maxUploadRetryTime(time: number); // (undocumented) _overrideAuthToken?: string; - // Warning: (ae-forgotten-export) The symbol "ConnectionPool" needs to be exported by the entry point index.d.ts - // - // (undocumented) - readonly _pool: ConnectionPool; // (undocumented) _protocol: string; // (undocumented) @@ -115,6 +112,12 @@ export interface FullMetadata extends UploadMetadata { updated: string; } +// @public +export function getBlob(ref: StorageReference, maxDownloadSizeBytes?: number): Promise; + +// @public +export function getBytes(ref: StorageReference, maxDownloadSizeBytes?: number): Promise; + // @internal (undocumented) export function _getChild(ref: StorageReference, childPath: string): _Reference; @@ -179,18 +182,26 @@ export function ref(storage: FirebaseStorage, url?: string): StorageReference; // @public export function ref(storageOrRef: FirebaseStorage | StorageReference, path?: string): StorageReference; -// @internal +// @public (undocumented) export class _Reference { + // Warning: (ae-incompatible-release-tags) The symbol "__constructor" is marked as @public, but its signature references "FirebaseStorageImpl" which is marked as @internal + // Warning: (ae-incompatible-release-tags) The symbol "__constructor" is marked as @public, but its signature references "Location" which is marked as @internal constructor(_service: _FirebaseStorageImpl, location: string | _Location); get bucket(): string; get fullPath(): string; + // Warning: (ae-incompatible-release-tags) The symbol "_location" is marked as @public, but its signature references "Location" which is marked as @internal + // // (undocumented) _location: _Location; get name(): string; + // Warning: (ae-incompatible-release-tags) The symbol "_newRef" is marked as @public, but its signature references "FirebaseStorageImpl" which is marked as @internal + // Warning: (ae-incompatible-release-tags) The symbol "_newRef" is marked as @public, but its signature references "Location" which is marked as @internal + // // (undocumented) protected _newRef(service: _FirebaseStorageImpl, location: _Location): _Reference; get parent(): _Reference | null; get root(): _Reference; + // Warning: (ae-incompatible-release-tags) The symbol "storage" is marked as @public, but its signature references "FirebaseStorageImpl" which is marked as @internal get storage(): _FirebaseStorageImpl; _throwIfRoot(name: string): void; // @override diff --git a/packages/storage/.run/All Tests.run.xml b/packages/storage/.run/All Tests.run.xml index 8b02b1f0490..ef02a556933 100644 --- a/packages/storage/.run/All Tests.run.xml +++ b/packages/storage/.run/All Tests.run.xml @@ -11,7 +11,7 @@ bdd - --require ts-node/register/type-check --require index.ts + --require ts-node/register/type-check --require src/index.node.ts PATTERN test/{,!(browser)/**/}*.test.ts diff --git a/packages/storage/karma.conf.js b/packages/storage/karma.conf.js index 3b5d7e3f39b..cae7c2a48e9 100644 --- a/packages/storage/karma.conf.js +++ b/packages/storage/karma.conf.js @@ -32,7 +32,7 @@ module.exports = function (config) { function getTestFiles(argv) { let unitTestFiles = ['test/unit/*']; - let integrationTestFiles = ['test/integration/*']; + let integrationTestFiles = ['test/integration/*', 'test/browser/*']; if (argv.unit) { return unitTestFiles; diff --git a/packages/storage/package.json b/packages/storage/package.json index 709dea3f07c..886810c1311 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -22,7 +22,7 @@ "test:browser:unit": "karma start --single-run --unit", "test:browser:integration": "karma start --single-run --integration", "test:browser": "karma start --single-run", - "test:node": "TS_NODE_FILES=true TS_NODE_CACHE=NO TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' nyc --reporter lcovonly -- mocha 'test/{,!(browser)/**/}*.test.ts' --file src/index.ts --config ../../config/mocharc.node.js", + "test:node": "TS_NODE_FILES=true TS_NODE_CACHE=NO TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' nyc --reporter lcovonly -- mocha 'test/{,!(browser)/**/}*.test.ts' --file src/index.node.ts --config ../../config/mocharc.node.js", "test:debug": "karma start --browser=Chrome", "prettier": "prettier --write 'src/**/*.ts' 'test/**/*.ts'", "api-report": "api-extractor run --local --verbose && ts-node-script ../../repo-scripts/prune-dts/prune-dts.ts --input dist/storage-public.d.ts --output dist/storage-public.d.ts", diff --git a/packages/storage/rollup.config.js b/packages/storage/rollup.config.js index f9c66fc7432..350c5ab6685 100644 --- a/packages/storage/rollup.config.js +++ b/packages/storage/rollup.config.js @@ -78,7 +78,7 @@ const es2017Plugins = [ const es2017Builds = [ // Node { - input: './src/index.ts', + input: './src/index.node.ts', output: { file: pkg.main, format: 'cjs', diff --git a/packages/storage/src/api.browser.ts b/packages/storage/src/api.browser.ts new file mode 100644 index 00000000000..68a177ab863 --- /dev/null +++ b/packages/storage/src/api.browser.ts @@ -0,0 +1,44 @@ +/** + * @license + * Copyright 2020 Google LLC + * + * 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. + */ + +import { StorageReference } from './public-types'; +import { Reference, getBlobInternal } from '../src/reference'; +import { getModularInstance } from '@firebase/util'; + +/** + * Downloads the data at the object's location. Returns an error if the object + * is not found. + * + * To use this functionality, you have to whitelist your app's origin in your + * Cloud Storage bucket. See also + * https://cloud.google.com/storage/docs/configuring-cors + * + * This API is not available in Node. + * + * @public + * @param ref - StorageReference where data should be download. + * @param maxDownloadSizeBytes - If set, the maximum allowed size in bytes to + * retrieve. + * @returns A Promise that resolves with a Blob containing the object's bytes + */ +export function getBlob( + ref: StorageReference, + maxDownloadSizeBytes?: number +): Promise { + ref = getModularInstance(ref); + return getBlobInternal(ref as Reference, maxDownloadSizeBytes); +} diff --git a/packages/storage/src/api.node.ts b/packages/storage/src/api.node.ts new file mode 100644 index 00000000000..fd4e7460dfa --- /dev/null +++ b/packages/storage/src/api.node.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright 2020 Google LLC + * + * 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. + */ + +import { StorageReference } from './public-types'; +import { Reference, getStreamInternal } from '../src/reference'; +import { getModularInstance } from '@firebase/util'; + +/** + * Downloads the data at the object's location. Raises an error event if the + * object is not found. + * + * This API is only available in Node. + * + * @public + * @param ref - StorageReference where data should be download. + * @param maxDownloadSizeBytes - If set, the maximum allowed size in bytes to + * retrieve. + * @returns A stream with the object's data as bytes + */ +export function getStream( + ref: StorageReference, + maxDownloadSizeBytes?: number +): NodeJS.ReadableStream { + ref = getModularInstance(ref); + return getStreamInternal(ref as Reference, maxDownloadSizeBytes); +} diff --git a/packages/storage/src/api.ts b/packages/storage/src/api.ts index 0766df05262..20eae852bac 100644 --- a/packages/storage/src/api.ts +++ b/packages/storage/src/api.ts @@ -47,7 +47,8 @@ import { getDownloadURL as getDownloadURLInternal, deleteObject as deleteObjectInternal, Reference, - _getChild as _getChildInternal + _getChild as _getChildInternal, + getBytesInternal } from './reference'; import { STORAGE_TYPE } from './constants'; import { EmulatorMockTokenOptions, getModularInstance } from '@firebase/util'; @@ -74,6 +75,29 @@ export { TaskEvent as _TaskEvent, TaskState as _TaskState } from './implementation/taskenums'; +export { StringFormat }; + +/** + * Downloads the data at the object's location. Returns an error if the object + * is not found. + * + * To use this functionality, you have to whitelist your app's origin in your + * Cloud Storage bucket. See also + * https://cloud.google.com/storage/docs/configuring-cors + * + * @public + * @param ref - StorageReference where data should be download. + * @param maxDownloadSizeBytes - If set, the maximum allowed size in bytes to + * retrieve. + * @returns A Promise containing the object's bytes + */ +export function getBytes( + ref: StorageReference, + maxDownloadSizeBytes?: number +): Promise { + ref = getModularInstance(ref); + return getBytesInternal(ref as Reference, maxDownloadSizeBytes); +} /** * Uploads data to this object's location. @@ -290,8 +314,6 @@ export function _getChild(ref: StorageReference, childPath: string): Reference { return _getChildInternal(ref as Reference, childPath); } -export { StringFormat } from './implementation/string'; - /** * Gets a {@link FirebaseStorage} instance for the given Firebase app. * @public diff --git a/packages/storage/src/implementation/connection.ts b/packages/storage/src/implementation/connection.ts index f575a0b29a4..bb8de81b192 100644 --- a/packages/storage/src/implementation/connection.ts +++ b/packages/storage/src/implementation/connection.ts @@ -15,18 +15,18 @@ * limitations under the License. */ -/** - * Network headers - */ -export interface Headers { - [name: string]: string; -} +/** Network headers */ +export type Headers = Record; /** * A lightweight wrapper around XMLHttpRequest with a * goog.net.XhrIo-like interface. + * + * ResponseType is generally either `string`, `ArrayBuffer` or `ReadableSteam`. + * You can create a new connection by invoking `newTextConnection()`, + * `newBytesConnection()` or `newStreamConnection()`. */ -export interface Connection { +export interface Connection { send( url: string, method: string, @@ -38,7 +38,9 @@ export interface Connection { getStatus(): number; - getResponseText(): string; + getResponse(): ResponseType; + + getErrorText(): string; /** * Abort the request. diff --git a/packages/storage/src/implementation/connectionPool.ts b/packages/storage/src/implementation/connectionPool.ts deleted file mode 100644 index 133f9a72523..00000000000 --- a/packages/storage/src/implementation/connectionPool.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * @license - * Copyright 2017 Google LLC - * - * 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. - */ - -/** - * @fileoverview Replacement for goog.net.XhrIoPool that works with fbs.XhrIo. - */ -import { Connection } from './connection'; -import { newConnection } from '../platform/connection'; - -/** - * Factory-like class for creating XhrIo instances. - */ -export class ConnectionPool { - createConnection(): Connection { - return newConnection(); - } -} diff --git a/packages/storage/src/implementation/error.ts b/packages/storage/src/implementation/error.ts index dab199db8d6..6d9f3bcb909 100644 --- a/packages/storage/src/implementation/error.ts +++ b/packages/storage/src/implementation/error.ts @@ -312,10 +312,7 @@ export function invalidRootOperation(name: string): StorageError { * @param format - The format that was not valid. * @param message - A message describing the format violation. */ -export function invalidFormat( - format: string, - message: string -): StorageError { +export function invalidFormat(format: string, message: string): StorageError { return new StorageError( StorageErrorCode.INVALID_FORMAT, "String does not match format '" + format + "': " + message @@ -326,10 +323,7 @@ export function invalidFormat( * @param message - A message describing the internal error. */ export function unsupportedEnvironment(message: string): StorageError { - throw new StorageError( - StorageErrorCode.UNSUPPORTED_ENVIRONMENT, - message - ); + throw new StorageError(StorageErrorCode.UNSUPPORTED_ENVIRONMENT, message); } /** diff --git a/packages/storage/src/implementation/request.ts b/packages/storage/src/implementation/request.ts index e7beb9dd5a4..ec69d2cae76 100644 --- a/packages/storage/src/implementation/request.ts +++ b/packages/storage/src/implementation/request.ts @@ -20,19 +20,12 @@ * abstract representations. */ -import { start, stop, id as backoffId } from './backoff'; -import { - StorageError, - unknown, - appDeleted, - canceled, - retryLimitExceeded -} from './error'; -import { RequestInfo } from './requestinfo'; +import { id as backoffId, start, stop } from './backoff'; +import { appDeleted, canceled, retryLimitExceeded, unknown } from './error'; +import { ErrorHandler, RequestHandler, RequestInfo } from './requestinfo'; import { isJustDef } from './type'; import { makeQueryString } from './url'; -import { Headers, Connection, ErrorCode } from './connection'; -import { ConnectionPool } from './connectionPool'; +import { Connection, ErrorCode, Headers } from './connection'; export interface Request { getPromise(): Promise; @@ -47,57 +40,40 @@ export interface Request { cancel(appDelete?: boolean): void; } -class NetworkRequest implements Request { - private url_: string; - private method_: string; - private headers_: Headers; - private body_: string | Blob | Uint8Array | null; - private successCodes_: number[]; - private additionalRetryCodes_: number[]; - private pendingConnection_: Connection | null = null; +/** + * Handles network logic for all Storage Requests, including error reporting and + * retries with backoff. + * + * @param I - the type of the backend's network response (always `string` or + * `ArrayBuffer`). + * @param - O the output type used by the rest of the SDK. The conversion + * happens in the specified `callback_`. + */ +class NetworkRequest implements Request { + private pendingConnection_: Connection | null = null; private backoffId_: backoffId | null = null; - private resolve_!: (value?: T | PromiseLike) => void; + private resolve_!: (value?: O | PromiseLike) => void; // eslint-disable-next-line @typescript-eslint/no-explicit-any private reject_!: (reason?: any) => void; private canceled_: boolean = false; private appDelete_: boolean = false; - private callback_: (p1: Connection, p2: string) => T; - private errorCallback_: - | ((p1: Connection, p2: StorageError) => StorageError) - | null; - private progressCallback_: ((p1: number, p2: number) => void) | null; - private timeout_: number; - private pool_: ConnectionPool; - promise_: Promise; + private promise_: Promise; constructor( - url: string, - method: string, - headers: Headers, - body: string | Blob | Uint8Array | null, - successCodes: number[], - additionalRetryCodes: number[], - callback: (p1: Connection, p2: string) => T, - errorCallback: - | ((p1: Connection, p2: StorageError) => StorageError) - | null, - timeout: number, - progressCallback: ((p1: number, p2: number) => void) | null, - pool: ConnectionPool + private url_: string, + private method_: string, + private headers_: Headers, + private body_: string | Blob | Uint8Array | null, + private successCodes_: number[], + private additionalRetryCodes_: number[], + private callback_: RequestHandler, + private errorCallback_: ErrorHandler | null, + private timeout_: number, + private progressCallback_: ((p1: number, p2: number) => void) | null, + private connectionFactory_: () => Connection ) { - this.url_ = url; - this.method_ = method; - this.headers_ = headers; - this.body_ = body; - this.successCodes_ = successCodes.slice(); - this.additionalRetryCodes_ = additionalRetryCodes.slice(); - this.callback_ = callback; - this.errorCallback_ = errorCallback; - this.progressCallback_ = progressCallback; - this.timeout_ = timeout; - this.pool_ = pool; this.promise_ = new Promise((resolve, reject) => { - this.resolve_ = resolve as (value?: T | PromiseLike) => void; + this.resolve_ = resolve as (value?: O | PromiseLike) => void; this.reject_ = reject; this.start_(); }); @@ -107,41 +83,43 @@ class NetworkRequest implements Request { * Actually starts the retry loop. */ private start_(): void { - const self = this; - - function doTheRequest( - backoffCallback: (p1: boolean, ...p2: unknown[]) => void, + const doTheRequest: ( + backoffCallback: (success: boolean, ...p2: unknown[]) => void, canceled: boolean - ): void { + ) => void = (backoffCallback, canceled) => { if (canceled) { backoffCallback(false, new RequestEndStatus(false, null, true)); return; } - const connection = self.pool_.createConnection(); - self.pendingConnection_ = connection; - function progressListener(progressEvent: ProgressEvent): void { - const loaded = progressEvent.loaded; - const total = progressEvent.lengthComputable ? progressEvent.total : -1; - if (self.progressCallback_ !== null) { - self.progressCallback_(loaded, total); - } - } - if (self.progressCallback_ !== null) { + const connection = this.connectionFactory_(); + this.pendingConnection_ = connection; + + const progressListener: (progressEvent: ProgressEvent) => void = + progressEvent => { + const loaded = progressEvent.loaded; + const total = progressEvent.lengthComputable + ? progressEvent.total + : -1; + if (this.progressCallback_ !== null) { + this.progressCallback_(loaded, total); + } + }; + if (this.progressCallback_ !== null) { connection.addUploadProgressListener(progressListener); } // eslint-disable-next-line @typescript-eslint/no-floating-promises connection - .send(self.url_, self.method_, self.body_, self.headers_) + .send(this.url_, this.method_, this.body_, this.headers_) .then(() => { - if (self.progressCallback_ !== null) { + if (this.progressCallback_ !== null) { connection.removeUploadProgressListener(progressListener); } - self.pendingConnection_ = null; + this.pendingConnection_ = null; const hitServer = connection.getErrorCode() === ErrorCode.NO_ERROR; const status = connection.getStatus(); - if (!hitServer || self.isRetryStatusCode_(status)) { + if (!hitServer || this.isRetryStatusCode_(status)) { const wasCanceled = connection.getErrorCode() === ErrorCode.ABORT; backoffCallback( false, @@ -149,28 +127,25 @@ class NetworkRequest implements Request { ); return; } - const successCode = self.successCodes_.indexOf(status) !== -1; + const successCode = this.successCodes_.indexOf(status) !== -1; backoffCallback(true, new RequestEndStatus(successCode, connection)); }); - } + }; /** * @param requestWentThrough - True if the request eventually went * through, false if it hit the retry limit or was canceled. */ - function backoffDone( + const backoffDone: ( requestWentThrough: boolean, - status: RequestEndStatus - ): void { - const resolve = self.resolve_; - const reject = self.reject_; - const connection = status.connection as Connection; + status: RequestEndStatus + ) => void = (requestWentThrough, status) => { + const resolve = this.resolve_; + const reject = this.reject_; + const connection = status.connection as Connection; if (status.wasSuccessCode) { try { - const result = self.callback_( - connection, - connection.getResponseText() - ); + const result = this.callback_(connection, connection.getResponse()); if (isJustDef(result)) { resolve(result); } else { @@ -182,15 +157,15 @@ class NetworkRequest implements Request { } else { if (connection !== null) { const err = unknown(); - err.serverResponse = connection.getResponseText(); - if (self.errorCallback_) { - reject(self.errorCallback_(connection, err)); + err.serverResponse = connection.getErrorText(); + if (this.errorCallback_) { + reject(this.errorCallback_(connection, err)); } else { reject(err); } } else { if (status.canceled) { - const err = self.appDelete_ ? appDeleted() : canceled(); + const err = this.appDelete_ ? appDeleted() : canceled(); reject(err); } else { const err = retryLimitExceeded(); @@ -198,7 +173,7 @@ class NetworkRequest implements Request { } } } - } + }; if (this.canceled_) { backoffDone(false, new RequestEndStatus(false, null, true)); } else { @@ -207,7 +182,7 @@ class NetworkRequest implements Request { } /** @inheritDoc */ - getPromise(): Promise { + getPromise(): Promise { return this.promise_; } @@ -244,7 +219,7 @@ class NetworkRequest implements Request { * A collection of information about the result of a network request. * @param opt_canceled - Defaults to false. */ -export class RequestEndStatus { +export class RequestEndStatus { /** * True if the request was canceled. */ @@ -252,7 +227,7 @@ export class RequestEndStatus { constructor( public wasSuccessCode: boolean, - public connection: Connection | null, + public connection: Connection | null, canceled?: boolean ) { this.canceled = !!canceled; @@ -291,14 +266,14 @@ export function addAppCheckHeader_( } } -export function makeRequest( - requestInfo: RequestInfo, +export function makeRequest( + requestInfo: RequestInfo, appId: string | null, authToken: string | null, appCheckToken: string | null, - pool: ConnectionPool, + requestFactory: () => Connection, firebaseVersion?: string -): Request { +): Request { const queryPart = makeQueryString(requestInfo.urlParams); const url = requestInfo.url + queryPart; const headers = Object.assign({}, requestInfo.headers); @@ -306,7 +281,7 @@ export function makeRequest( addAuthHeader_(headers, authToken); addVersionHeader_(headers, firebaseVersion); addAppCheckHeader_(headers, appCheckToken); - return new NetworkRequest( + return new NetworkRequest( url, requestInfo.method, headers, @@ -317,6 +292,6 @@ export function makeRequest( requestInfo.errorHandler, requestInfo.timeout, requestInfo.progressCallback, - pool + requestFactory ); } diff --git a/packages/storage/src/implementation/requestinfo.ts b/packages/storage/src/implementation/requestinfo.ts index 1b9b4ab308a..68ad2ccc503 100644 --- a/packages/storage/src/implementation/requestinfo.ts +++ b/packages/storage/src/implementation/requestinfo.ts @@ -24,14 +24,37 @@ export interface UrlParams { [name: string]: string | number; } -export class RequestInfo { +/** + * A function that converts a server response to the API type expected by the + * SDK. + * + * @param I - the type of the backend's network response (always `string` or + * `ArrayBuffer`). + * @param O - the output response type used by the rest of the SDK. + */ +export type RequestHandler = ( + connection: Connection, + response: I +) => O; + +/** A function to handle an error. */ +export type ErrorHandler = ( + connection: Connection, + response: StorageError +) => StorageError; + +/** + * Contains a fully specified request. + * + * @param I - the type of the backend's network response (always `string` or + * `ArrayBuffer`). + * @param O - the output response type used by the rest of the SDK. + */ +export class RequestInfo { urlParams: UrlParams = {}; headers: Headers = {}; body: Blob | string | Uint8Array | null = null; - - errorHandler: - | ((p1: Connection, p2: StorageError) => StorageError) - | null = null; + errorHandler: ErrorHandler | null = null; /** * Called with the current number of bytes uploaded and total size (-1 if not @@ -51,7 +74,7 @@ export class RequestInfo { * Note: The XhrIo passed to this function may be reused after this callback * returns. Do not keep a reference to it in any way. */ - public handler: (p1: Connection, p2: string) => T, + public handler: RequestHandler, public timeout: number ) {} } diff --git a/packages/storage/src/implementation/requestmaker.ts b/packages/storage/src/implementation/requestmaker.ts deleted file mode 100644 index eede271f062..00000000000 --- a/packages/storage/src/implementation/requestmaker.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * @license - * Copyright 2017 Google LLC - * - * 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. - */ -import { Request } from './request'; -import { RequestInfo } from './requestinfo'; -import { ConnectionPool } from './connectionPool'; - -type requestMaker = ( - requestInfo: RequestInfo, - appId: string | null, - authToken: string | null, - pool: ConnectionPool -) => Request; - -export { requestMaker }; diff --git a/packages/storage/src/implementation/requests.ts b/packages/storage/src/implementation/requests.ts index 8b62702156f..9206abb3638 100644 --- a/packages/storage/src/implementation/requests.ts +++ b/packages/storage/src/implementation/requests.ts @@ -41,7 +41,7 @@ import { toResourceString } from './metadata'; import { fromResponseString } from './list'; -import { RequestInfo, UrlParams } from './requestinfo'; +import { RequestHandler, RequestInfo, UrlParams } from './requestinfo'; import { isString } from './type'; import { makeUrl } from './url'; import { Connection } from './connection'; @@ -59,8 +59,8 @@ export function handlerCheck(cndn: boolean): void { export function metadataHandler( service: FirebaseStorageImpl, mappings: Mappings -): (p1: Connection, p2: string) => Metadata { - function handler(xhr: Connection, text: string): Metadata { +): (p1: Connection, p2: string) => Metadata { + function handler(xhr: Connection, text: string): Metadata { const metadata = fromResourceString(service, text, mappings); handlerCheck(metadata !== null); return metadata as Metadata; @@ -71,8 +71,8 @@ export function metadataHandler( export function listHandler( service: FirebaseStorageImpl, bucket: string -): (p1: Connection, p2: string) => ListResult { - function handler(xhr: Connection, text: string): ListResult { +): (p1: Connection, p2: string) => ListResult { + function handler(xhr: Connection, text: string): ListResult { const listResult = fromResponseString(service, bucket, text); handlerCheck(listResult !== null); return listResult as ListResult; @@ -83,8 +83,8 @@ export function listHandler( export function downloadUrlHandler( service: FirebaseStorageImpl, mappings: Mappings -): (p1: Connection, p2: string) => string | null { - function handler(xhr: Connection, text: string): string | null { +): (p1: Connection, p2: string) => string | null { + function handler(xhr: Connection, text: string): string | null { const metadata = fromResourceString(service, text, mappings); handlerCheck(metadata !== null); return downloadUrlFromResourceString( @@ -99,14 +99,17 @@ export function downloadUrlHandler( export function sharedErrorHandler( location: Location -): (p1: Connection, p2: StorageError) => StorageError { - function errorHandler(xhr: Connection, err: StorageError): StorageError { +): (p1: Connection, p2: StorageError) => StorageError { + function errorHandler( + xhr: Connection, + err: StorageError + ): StorageError { let newErr; if (xhr.getStatus() === 401) { if ( // This exact message string is the only consistent part of the // server's error response that identifies it as an App Check error. - xhr.getResponseText().includes('Firebase App Check token is invalid') + xhr.getErrorText().includes('Firebase App Check token is invalid') ) { newErr = unauthorizedApp(); } else { @@ -131,10 +134,13 @@ export function sharedErrorHandler( export function objectErrorHandler( location: Location -): (p1: Connection, p2: StorageError) => StorageError { +): (p1: Connection, p2: StorageError) => StorageError { const shared = sharedErrorHandler(location); - function errorHandler(xhr: Connection, err: StorageError): StorageError { + function errorHandler( + xhr: Connection, + err: StorageError + ): StorageError { let newErr = shared(xhr, err); if (xhr.getStatus() === 404) { newErr = objectNotFound(location.path); @@ -149,7 +155,7 @@ export function getMetadata( service: FirebaseStorageImpl, location: Location, mappings: Mappings -): RequestInfo { +): RequestInfo { const urlPart = location.fullServerUrl(); const url = makeUrl(urlPart, service.host, service._protocol); const method = 'GET'; @@ -170,7 +176,7 @@ export function list( delimiter?: string, pageToken?: string | null, maxResults?: number | null -): RequestInfo { +): RequestInfo { const urlParams: UrlParams = {}; if (location.isRoot) { urlParams['prefix'] = ''; @@ -201,11 +207,88 @@ export function list( return requestInfo; } +export function getBytes( + service: FirebaseStorageImpl, + location: Location, + maxDownloadSizeBytes?: number +): RequestInfo { + return createDownloadRequest( + location, + service, + getBytesHandler(), + maxDownloadSizeBytes + ); +} + +export function getBytesHandler(): RequestHandler { + return (xhr: Connection, data: ArrayBuffer) => data; +} + +export function getBlob( + service: FirebaseStorageImpl, + location: Location, + maxDownloadSizeBytes?: number +): RequestInfo { + return createDownloadRequest( + location, + service, + getBlobHandler(), + maxDownloadSizeBytes + ); +} + +export function getBlobHandler(): RequestHandler { + return (xhr: Connection, data: Blob) => data; +} + +export function getStream( + service: FirebaseStorageImpl, + location: Location, + maxDownloadSizeBytes?: number +): RequestInfo { + return createDownloadRequest( + location, + service, + getStreamHandler(), + maxDownloadSizeBytes + ); +} + +export function getStreamHandler(): RequestHandler< + NodeJS.ReadableStream, + NodeJS.ReadableStream +> { + return ( + xhr: Connection, + data: NodeJS.ReadableStream + ) => data; +} + +/** Creates a new request to download an object. */ +function createDownloadRequest( + location: Location, + service: FirebaseStorageImpl, + handler: RequestHandler, + maxDownloadSizeBytes?: number +): RequestInfo { + const urlPart = location.fullServerUrl(); + const url = makeUrl(urlPart, service.host, service._protocol) + '?alt=media'; + const method = 'GET'; + const timeout = service.maxOperationRetryTime; + const requestInfo = new RequestInfo(url, method, handler, timeout); + requestInfo.errorHandler = objectErrorHandler(location); + if (maxDownloadSizeBytes !== undefined) { + requestInfo.headers['Range'] = `bytes=0-${maxDownloadSizeBytes}`; + requestInfo.successCodes = [200 /* OK */, 206 /* Partial Content */]; + } + return requestInfo; +} + export function getDownloadUrl( service: FirebaseStorageImpl, location: Location, mappings: Mappings -): RequestInfo { +): RequestInfo { const urlPart = location.fullServerUrl(); const url = makeUrl(urlPart, service.host, service._protocol); const method = 'GET'; @@ -225,7 +308,7 @@ export function updateMetadata( location: Location, metadata: Partial, mappings: Mappings -): RequestInfo { +): RequestInfo { const urlPart = location.fullServerUrl(); const url = makeUrl(urlPart, service.host, service._protocol); const method = 'PATCH'; @@ -247,13 +330,13 @@ export function updateMetadata( export function deleteObject( service: FirebaseStorageImpl, location: Location -): RequestInfo { +): RequestInfo { const urlPart = location.fullServerUrl(); const url = makeUrl(urlPart, service.host, service._protocol); const method = 'DELETE'; const timeout = service.maxOperationRetryTime; - function handler(_xhr: Connection, _text: string): void {} + function handler(_xhr: Connection, _text: string): void {} const requestInfo = new RequestInfo(url, method, handler, timeout); requestInfo.successCodes = [200, 204]; requestInfo.errorHandler = objectErrorHandler(location); @@ -294,7 +377,7 @@ export function multipartUpload( mappings: Mappings, blob: FbsBlob, metadata?: Metadata | null -): RequestInfo { +): RequestInfo { const urlPart = location.bucketOnlyServerUrl(); const headers: { [prop: string]: string } = { 'X-Goog-Upload-Protocol': 'multipart' @@ -368,7 +451,7 @@ export class ResumableUploadStatus { } export function checkResumeHeader_( - xhr: Connection, + xhr: Connection, allowed?: string[] ): string { let status: string | null = null; @@ -388,7 +471,7 @@ export function createResumableUpload( mappings: Mappings, blob: FbsBlob, metadata?: Metadata | null -): RequestInfo { +): RequestInfo { const urlPart = location.bucketOnlyServerUrl(); const metadataForUpload = metadataForUpload_(location, blob, metadata); const urlParams: UrlParams = { name: metadataForUpload['fullPath']! }; @@ -404,7 +487,7 @@ export function createResumableUpload( const body = toResourceString(metadataForUpload, mappings); const timeout = service.maxUploadRetryTime; - function handler(xhr: Connection): string { + function handler(xhr: Connection): string { checkResumeHeader_(xhr); let url; try { @@ -431,10 +514,10 @@ export function getResumableUploadStatus( location: Location, url: string, blob: FbsBlob -): RequestInfo { +): RequestInfo { const headers = { 'X-Goog-Upload-Command': 'query' }; - function handler(xhr: Connection): ResumableUploadStatus { + function handler(xhr: Connection): ResumableUploadStatus { const status = checkResumeHeader_(xhr, ['active', 'final']); let sizeString: string | null = null; try { @@ -484,7 +567,7 @@ export function continueResumableUpload( mappings: Mappings, status?: ResumableUploadStatus | null, progressCallback?: ((p1: number, p2: number) => void) | null -): RequestInfo { +): RequestInfo { // TODO(andysoto): standardize on internal asserts // assert(!(opt_status && opt_status.finalized)); const status_ = new ResumableUploadStatus(0, 0); @@ -516,7 +599,10 @@ export function continueResumableUpload( throw cannotSliceBlob(); } - function handler(xhr: Connection, text: string): ResumableUploadStatus { + function handler( + xhr: Connection, + text: string + ): ResumableUploadStatus { // TODO(andysoto): Verify the MD5 of each uploaded range: // the 'x-range-md5' header comes back with status code 308 responses. // We'll only be able to bail out though, because you can't re-upload a diff --git a/packages/storage/src/index.node.ts b/packages/storage/src/index.node.ts new file mode 100644 index 00000000000..89af06170d4 --- /dev/null +++ b/packages/storage/src/index.node.ts @@ -0,0 +1,76 @@ +/** + * Cloud Storage for Firebase + * + * @packageDocumentation + */ + +/** + * @license + * Copyright 2021 Google LLC + * + * 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. + */ + +// eslint-disable-next-line import/no-extraneous-dependencies +import { + _registerComponent, + registerVersion, + SDK_VERSION +} from '@firebase/app'; + +import { FirebaseStorageImpl } from './service'; +import { + Component, + ComponentType, + ComponentContainer, + InstanceFactoryOptions +} from '@firebase/component'; + +import { name, version } from '../package.json'; + +import { FirebaseStorage } from './public-types'; +import { STORAGE_TYPE } from './constants'; + +export * from './api'; +export * from './api.node'; + +function factory( + container: ComponentContainer, + { instanceIdentifier: url }: InstanceFactoryOptions +): FirebaseStorage { + const app = container.getProvider('app').getImmediate(); + const authProvider = container.getProvider('auth-internal'); + const appCheckProvider = container.getProvider('app-check-internal'); + + return new FirebaseStorageImpl( + app, + authProvider, + appCheckProvider, + url, + SDK_VERSION + ); +} + +function registerStorage(): void { + _registerComponent( + new Component( + STORAGE_TYPE, + factory, + ComponentType.PUBLIC + ).setMultipleInstances(true) + ); + + registerVersion(name, version); +} + +registerStorage(); diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts index d200b1fb536..b3bf4b9801c 100644 --- a/packages/storage/src/index.ts +++ b/packages/storage/src/index.ts @@ -27,8 +27,7 @@ import { SDK_VERSION } from '@firebase/app'; -import { ConnectionPool } from '../src/implementation/connectionPool'; -import { FirebaseStorageImpl } from '../src/service'; +import { FirebaseStorageImpl } from './service'; import { Component, ComponentType, @@ -41,8 +40,8 @@ import { name, version } from '../package.json'; import { FirebaseStorage } from './public-types'; import { STORAGE_TYPE } from './constants'; -export { StringFormat } from '../src/implementation/string'; export * from './api'; +export * from './api.browser'; function factory( container: ComponentContainer, @@ -56,7 +55,6 @@ function factory( app, authProvider, appCheckProvider, - new ConnectionPool(), url, SDK_VERSION ); diff --git a/packages/storage/src/platform/browser/connection.ts b/packages/storage/src/platform/browser/connection.ts index 9b26087779d..18ff8788019 100644 --- a/packages/storage/src/platform/browser/connection.ts +++ b/packages/storage/src/platform/browser/connection.ts @@ -22,18 +22,22 @@ import { } from '../../implementation/connection'; import { internalError } from '../../implementation/error'; +/** An override for the text-based Connection. Used in tests. */ +let textFactoryOverride: (() => Connection) | null = null; + /** * Network layer for browsers. We use this instead of goog.net.XhrIo because * goog.net.XhrIo is hyuuuuge and doesn't work in React Native on Android. */ -export class XhrConnection implements Connection { - private xhr_: XMLHttpRequest; +abstract class XhrConnection implements Connection { + protected xhr_: XMLHttpRequest; private errorCode_: ErrorCode; private sendPromise_: Promise; - private sent_: boolean = false; + protected sent_: boolean = false; constructor() { this.xhr_ = new XMLHttpRequest(); + this.initXhr(); this.errorCode_ = ErrorCode.NO_ERROR; this.sendPromise_ = new Promise(resolve => { this.xhr_.addEventListener('abort', () => { @@ -50,9 +54,8 @@ export class XhrConnection implements Connection { }); } - /** - * @override - */ + abstract initXhr(): void; + send( url: string, method: string, @@ -79,9 +82,6 @@ export class XhrConnection implements Connection { return this.sendPromise_; } - /** - * @override - */ getErrorCode(): ErrorCode { if (!this.sent_) { throw internalError('cannot .getErrorCode() before sending'); @@ -89,9 +89,6 @@ export class XhrConnection implements Connection { return this.errorCode_; } - /** - * @override - */ getStatus(): number { if (!this.sent_) { throw internalError('cannot .getStatus() before sending'); @@ -103,43 +100,30 @@ export class XhrConnection implements Connection { } } - /** - * @override - */ - getResponseText(): string { + abstract getResponse(): ResponseType; + + getErrorText(): string { if (!this.sent_) { throw internalError('cannot .getResponseText() before sending'); } return this.xhr_.responseText; } - /** - * Aborts the request. - * @override - */ + /** Aborts the request. */ abort(): void { this.xhr_.abort(); } - /** - * @override - */ getResponseHeader(header: string): string | null { return this.xhr_.getResponseHeader(header); } - /** - * @override - */ addUploadProgressListener(listener: (p1: ProgressEvent) => void): void { if (this.xhr_.upload != null) { this.xhr_.upload.addEventListener('progress', listener); } } - /** - * @override - */ removeUploadProgressListener(listener: (p1: ProgressEvent) => void): void { if (this.xhr_.upload != null) { this.xhr_.upload.removeEventListener('progress', listener); @@ -147,6 +131,117 @@ export class XhrConnection implements Connection { } } -export function newConnection(): Connection { - return new XhrConnection(); +export class XhrTextConnection extends XhrConnection { + initXhr(): void { + this.xhr_.responseType = 'text'; + } + + getResponse(): string { + if (!this.sent_) { + throw internalError('cannot .getResponse() before sending'); + } + return this.xhr_.response; + } +} + +export function newTextConnection(): Connection { + return textFactoryOverride ? textFactoryOverride() : new XhrTextConnection(); +} + +export class XhrBytesConnection extends XhrConnection { + private data_?: ArrayBuffer; + + initXhr(): void { + // We use Blob here instead of ArrayBuffer to ensure that this code works + // in Opera. + this.xhr_.responseType = 'blob'; + } + + getErrorText(): string { + if (!this.sent_) { + throw internalError('cannot .getResponseText() before sending'); + } + return new TextDecoder().decode(this.data_); + } + + getResponse(): ArrayBuffer { + if (!this.sent_) { + throw internalError('cannot .getResponse() before sending'); + } + return this.data_!; + } + + send( + url: string, + method: string, + body?: ArrayBufferView | Blob | string, + headers?: Headers + ): Promise { + return super + .send(url, method, body, headers) + .then(() => (this.xhr_.response as Blob).arrayBuffer()) + .then(data => { + this.data_ = data; + }); + } +} + +export function newBytesConnection(): Connection { + return new XhrBytesConnection(); +} + +const MAX_ERROR_MSG_LENGTH = 512; + +export class XhrBlobConnection extends XhrConnection { + private data_?: Blob; + private text_?: string; + + initXhr(): void { + this.xhr_.responseType = 'blob'; + } + + getErrorText(): string { + if (!this.sent_) { + throw internalError('cannot .getResponseText() before sending'); + } + return this.text_!; + } + + getResponse(): Blob { + if (!this.sent_) { + throw internalError('cannot .getResponse() before sending'); + } + return this.data_!; + } + + send( + url: string, + method: string, + body?: ArrayBufferView | Blob | string, + headers?: Headers + ): Promise { + return super + .send(url, method, body, headers) + .then(() => { + this.data_ = this.xhr_.response; + return this.data_!.slice(0, MAX_ERROR_MSG_LENGTH, 'string').text(); + }) + .then(text => { + this.text_ = text; + }); + } +} + +export function newBlobConnection(): Connection { + return new XhrBlobConnection(); +} + +export function newStreamConnection(): Connection { + throw new Error('Streams are only supported on Node'); +} + +export function injectTestConnection( + factory: (() => Connection) | null +): void { + textFactoryOverride = factory; } diff --git a/packages/storage/src/platform/connection.ts b/packages/storage/src/platform/connection.ts index bfcc0a1f92a..f1732312e2d 100644 --- a/packages/storage/src/platform/connection.ts +++ b/packages/storage/src/platform/connection.ts @@ -15,9 +15,37 @@ * limitations under the License. */ import { Connection } from '../implementation/connection'; -import { newConnection as nodeNewConnection } from './node/connection'; +import { + newTextConnection as nodeNewTextConnection, + newBytesConnection as nodeNewBytesConnection, + newBlobConnection as nodeNewBlobConnection, + newStreamConnection as nodeNewStreamConnection, + injectTestConnection as nodeInjectTestConnection +} from './node/connection'; -export function newConnection(): Connection { +export function injectTestConnection( + factory: (() => Connection) | null +): void { // This file is only used under ts-node. - return nodeNewConnection(); + nodeInjectTestConnection(factory); +} + +export function newTextConnection(): Connection { + // This file is only used under ts-node. + return nodeNewTextConnection(); +} + +export function newBytesConnection(): Connection { + // This file is only used under ts-node. + return nodeNewBytesConnection(); +} + +export function newBlobConnection(): Connection { + // This file is only used under ts-node. + return nodeNewBlobConnection(); +} + +export function newStreamConnection(): Connection { + // This file is only used under ts-node. + return nodeNewStreamConnection(); } diff --git a/packages/storage/src/platform/node/connection.ts b/packages/storage/src/platform/node/connection.ts index 1d4633fd77b..9656ef179b0 100644 --- a/packages/storage/src/platform/node/connection.ts +++ b/packages/storage/src/platform/node/connection.ts @@ -15,12 +15,12 @@ * limitations under the License. */ -import { ErrorCode, Connection } from '../../implementation/connection'; +import { Connection, ErrorCode } from '../../implementation/connection'; import { internalError } from '../../implementation/error'; -import nodeFetch from 'node-fetch'; +import nodeFetch, { Headers } from 'node-fetch'; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const fetch: typeof window.fetch = nodeFetch as any; +/** An override for the text-based Connection. Used in tests. */ +let textFactoryOverride: (() => Connection) | null = null; /** * Network layer that works in Node. @@ -28,18 +28,21 @@ const fetch: typeof window.fetch = nodeFetch as any; * This network implementation should not be used in browsers as it does not * support progress updates. */ -export class FetchConnection implements Connection { - private errorCode_: ErrorCode; - private statusCode_: number | undefined; - private body_: string | undefined; - private headers_: Headers | undefined; - private sent_: boolean = false; +abstract class FetchConnection + implements Connection +{ + protected errorCode_: ErrorCode; + protected statusCode_: number | undefined; + protected body_: ArrayBuffer | undefined; + protected errorText_ = ''; + protected headers_: Headers | undefined; + protected sent_: boolean = false; constructor() { this.errorCode_ = ErrorCode.NO_ERROR; } - send( + async send( url: string, method: string, body?: ArrayBufferView | Blob | string, @@ -50,19 +53,20 @@ export class FetchConnection implements Connection { } this.sent_ = true; - return fetch(url, { - method, - headers: headers || {}, - body - }) - .then(resp => { - this.headers_ = resp.headers; - this.statusCode_ = resp.status; - return resp.text(); - }) - .then(body => { - this.body_ = body; + try { + const response = await nodeFetch(url, { + method, + headers: headers || {}, + body: body as ArrayBufferView | string }); + this.headers_ = response.headers; + this.statusCode_ = response.status; + this.errorCode_ = ErrorCode.NO_ERROR; + this.body_ = await response.arrayBuffer(); + } catch (e) { + this.errorText_ = e.message; + this.errorCode_ = ErrorCode.NETWORK_ERROR; + } } getErrorCode(): ErrorCode { @@ -79,13 +83,10 @@ export class FetchConnection implements Connection { return this.statusCode_; } - getResponseText(): string { - if (this.body_ === undefined) { - throw internalError( - 'cannot .getResponseText() before receiving response' - ); - } - return this.body_; + abstract getResponse(): ResponseType; + + getErrorText(): string { + return this.errorText_; } abort(): void { @@ -113,6 +114,88 @@ export class FetchConnection implements Connection { } } -export function newConnection(): Connection { - return new FetchConnection(); +export class FetchTextConnection extends FetchConnection { + getResponse(): string { + if (this.body_ === undefined) { + throw internalError( + 'cannot .getResponseText() before receiving response' + ); + } + return Buffer.from(this.body_).toString('utf-8'); + } +} + +export function newTextConnection(): Connection { + return textFactoryOverride + ? textFactoryOverride() + : new FetchTextConnection(); +} + +export class FetchBytesConnection extends FetchConnection { + getResponse(): ArrayBuffer { + if (!this.body_) { + throw internalError('cannot .getResponse() before sending'); + } + return this.body_; + } +} + +export function newBytesConnection(): Connection { + return new FetchBytesConnection(); +} + +export class FetchStreamConnection extends FetchConnection { + private stream_: NodeJS.ReadableStream | null = null; + + async send( + url: string, + method: string, + body?: ArrayBufferView | Blob | string, + headers?: Record + ): Promise { + if (this.sent_) { + throw internalError('cannot .send() more than once'); + } + this.sent_ = true; + + try { + const response = await nodeFetch(url, { + method, + headers: headers || {}, + body: body as ArrayBufferView | string + }); + this.headers_ = response.headers; + this.statusCode_ = response.status; + this.errorCode_ = ErrorCode.NO_ERROR; + this.stream_ = response.body; + } catch (e) { + this.errorText_ = e.message; + this.errorCode_ = ErrorCode.NETWORK_ERROR; + } + } + + getResponse(): NodeJS.ReadableStream { + if (!this.stream_) { + throw internalError('cannot .getResponse() before sending'); + } + return this.stream_; + } + + getErrorText(): string { + return this.errorText_; + } +} + +export function newStreamConnection(): Connection { + return new FetchStreamConnection(); +} + +export function newBlobConnection(): Connection { + throw new Error('Blobs are not supported on Node'); +} + +export function injectTestConnection( + factory: (() => Connection) | null +): void { + textFactoryOverride = factory; } diff --git a/packages/storage/src/reference.ts b/packages/storage/src/reference.ts index d925ba2091a..2d4102f284b 100644 --- a/packages/storage/src/reference.ts +++ b/packages/storage/src/reference.ts @@ -19,6 +19,8 @@ * @fileoverview Defines the Firebase StorageReference class. */ +import { Transform, TransformOptions, PassThrough } from 'stream'; + import { FbsBlob } from './implementation/blob'; import { Location } from './implementation/location'; import { getMappings } from './implementation/metadata'; @@ -29,7 +31,10 @@ import { updateMetadata as requestsUpdateMetadata, getDownloadUrl as requestsGetDownloadUrl, deleteObject as requestsDeleteObject, - multipartUpload + multipartUpload, + getBytes, + getBlob, + getStream } from './implementation/requests'; import { ListOptions, UploadResult } from './public-types'; import { StringFormat, dataFromString } from './implementation/string'; @@ -39,19 +44,13 @@ import { ListResult } from './list'; import { UploadTask } from './task'; import { invalidRootOperation, noDownloadURL } from './implementation/error'; import { validateNumber } from './implementation/type'; +import { + newBytesConnection, + newTextConnection, + newBlobConnection, + newStreamConnection +} from './platform/connection'; -/** - * Provides methods to interact with a bucket in the Firebase Storage service. - * @internal - * @param _location - An fbs.location, or the URL at - * which to base this object, in one of the following forms: - * gs:/// - * http[s]://firebasestorage.googleapis.com/ - * /b//o/ - * Any query or fragment strings will be ignored in the http[s] - * format. If no value is passed, the storage object will use a URL based on - * the project ID of the base firebase.App instance. - */ export class Reference { _location: Location; @@ -142,6 +141,92 @@ export class Reference { } } +/** + * Download the bytes at the object's location. + * @returns A Promise containing the downloaded bytes. + */ +export function getBytesInternal( + ref: Reference, + maxDownloadSizeBytes?: number +): Promise { + ref._throwIfRoot('getBytes'); + const requestInfo = getBytes( + ref.storage, + ref._location, + maxDownloadSizeBytes + ); + return ref.storage + .makeRequestWithTokens(requestInfo, newBytesConnection) + .then(bytes => + maxDownloadSizeBytes !== undefined + ? // GCS may not honor the Range header for small files + bytes.slice(0, maxDownloadSizeBytes) + : bytes + ); +} + +/** + * Download the bytes at the object's location. + * @returns A Promise containing the downloaded blob. + */ +export function getBlobInternal( + ref: Reference, + maxDownloadSizeBytes?: number +): Promise { + ref._throwIfRoot('getBlob'); + const requestInfo = getBlob(ref.storage, ref._location, maxDownloadSizeBytes); + return ref.storage + .makeRequestWithTokens(requestInfo, newBlobConnection) + .then(blob => + maxDownloadSizeBytes !== undefined + ? // GCS may not honor the Range header for small files + blob.slice(0, maxDownloadSizeBytes) + : blob + ); +} + +/** Stream the bytes at the object's location. */ +export function getStreamInternal( + ref: Reference, + maxDownloadSizeBytes?: number +): NodeJS.ReadableStream { + ref._throwIfRoot('getStream'); + const requestInfo = getStream( + ref.storage, + ref._location, + maxDownloadSizeBytes + ); + + /** A transformer that passes through the first n bytes. */ + const newMaxSizeTransform: (n: number) => TransformOptions = n => { + let missingBytes = n; + return { + transform(chunk, encoding, callback) { + // GCS may not honor the Range header for small files + if (chunk.length < missingBytes) { + this.push(chunk); + missingBytes -= chunk.length; + } else { + this.push(chunk.slice(0, missingBytes)); + this.emit('end'); + } + callback(); + } + } as TransformOptions; + }; + + const result = + maxDownloadSizeBytes !== undefined + ? new Transform(newMaxSizeTransform(maxDownloadSizeBytes)) + : new PassThrough(); + + ref.storage + .makeRequestWithTokens(requestInfo, newStreamConnection) + .then(stream => stream.pipe(result)) + .catch(e => result.destroy(e)); + return result; +} + /** * Uploads data to this object's location. * The upload is not resumable. @@ -165,8 +250,7 @@ export function uploadBytes( metadata ); return ref.storage - .makeRequestWithTokens(requestInfo) - .then(request => request.getPromise()) + .makeRequestWithTokens(requestInfo, newTextConnection) .then(finalMetadata => { return { metadata: finalMetadata, @@ -290,7 +374,7 @@ async function listAllHelper( * contains references to objects in this folder. `nextPageToken` * can be used to get the rest of the results. */ -export async function list( +export function list( ref: Reference, options?: ListOptions | null ): Promise { @@ -312,7 +396,7 @@ export async function list( op.pageToken, op.maxResults ); - return (await ref.storage.makeRequestWithTokens(requestInfo)).getPromise(); + return ref.storage.makeRequestWithTokens(requestInfo, newTextConnection); } /** @@ -322,14 +406,14 @@ export async function list( * @public * @param ref - StorageReference to get metadata from. */ -export async function getMetadata(ref: Reference): Promise { +export function getMetadata(ref: Reference): Promise { ref._throwIfRoot('getMetadata'); const requestInfo = requestsGetMetadata( ref.storage, ref._location, getMappings() ); - return (await ref.storage.makeRequestWithTokens(requestInfo)).getPromise(); + return ref.storage.makeRequestWithTokens(requestInfo, newTextConnection); } /** @@ -343,7 +427,7 @@ export async function getMetadata(ref: Reference): Promise { * with the new metadata for this object. * See `firebaseStorage.Reference.prototype.getMetadata` */ -export async function updateMetadata( +export function updateMetadata( ref: Reference, metadata: Partial ): Promise { @@ -354,7 +438,7 @@ export async function updateMetadata( metadata, getMappings() ); - return (await ref.storage.makeRequestWithTokens(requestInfo)).getPromise(); + return ref.storage.makeRequestWithTokens(requestInfo, newTextConnection); } /** @@ -363,15 +447,15 @@ export async function updateMetadata( * @returns A `Promise` that resolves with the download * URL for this object. */ -export async function getDownloadURL(ref: Reference): Promise { +export function getDownloadURL(ref: Reference): Promise { ref._throwIfRoot('getDownloadURL'); const requestInfo = requestsGetDownloadUrl( ref.storage, ref._location, getMappings() ); - return (await ref.storage.makeRequestWithTokens(requestInfo)) - .getPromise() + return ref.storage + .makeRequestWithTokens(requestInfo, newTextConnection) .then(url => { if (url === null) { throw noDownloadURL(); @@ -386,10 +470,10 @@ export async function getDownloadURL(ref: Reference): Promise { * @param ref - StorageReference for object to delete. * @returns A `Promise` that resolves if the deletion succeeds. */ -export async function deleteObject(ref: Reference): Promise { +export function deleteObject(ref: Reference): Promise { ref._throwIfRoot('deleteObject'); const requestInfo = requestsDeleteObject(ref.storage, ref._location); - return (await ref.storage.makeRequestWithTokens(requestInfo)).getPromise(); + return ref.storage.makeRequestWithTokens(requestInfo, newTextConnection); } /** diff --git a/packages/storage/src/service.ts b/packages/storage/src/service.ts index b54ac313cc7..48bcde72234 100644 --- a/packages/storage/src/service.ts +++ b/packages/storage/src/service.ts @@ -19,7 +19,6 @@ import { Location } from './implementation/location'; import { FailRequest } from './implementation/failrequest'; import { Request, makeRequest } from './implementation/request'; import { RequestInfo } from './implementation/requestinfo'; -import { ConnectionPool } from './implementation/connectionPool'; import { Reference, _getChild } from './reference'; import { Provider } from '@firebase/component'; import { FirebaseAuthInternalName } from '@firebase/auth-interop-types'; @@ -31,13 +30,14 @@ import { DEFAULT_HOST, DEFAULT_MAX_OPERATION_RETRY_TIME, DEFAULT_MAX_UPLOAD_RETRY_TIME -} from '../src/implementation/constants'; +} from './implementation/constants'; import { invalidArgument, appDeleted, noDefaultBucket } from './implementation/error'; import { validateNumber } from './implementation/type'; +import { Connection } from './implementation/connection'; import { FirebaseStorage } from './public-types'; import { createMockUserToken, EmulatorMockTokenOptions } from '@firebase/util'; @@ -182,7 +182,6 @@ export class FirebaseStorageImpl implements FirebaseStorage { /** * @internal */ - readonly _pool: ConnectionPool, readonly _url?: string, readonly _firebaseVersion?: string ) { @@ -299,18 +298,19 @@ export class FirebaseStorageImpl implements FirebaseStorage { * @param requestInfo - HTTP RequestInfo object * @param authToken - Firebase auth token */ - _makeRequest( - requestInfo: RequestInfo, + _makeRequest( + requestInfo: RequestInfo, + requestFactory: () => Connection, authToken: string | null, appCheckToken: string | null - ): Request { + ): Request { if (!this._deleted) { const request = makeRequest( requestInfo, this._appId, authToken, appCheckToken, - this._pool, + requestFactory, this._firebaseVersion ); this._requests.add(request); @@ -325,14 +325,20 @@ export class FirebaseStorageImpl implements FirebaseStorage { } } - async makeRequestWithTokens( - requestInfo: RequestInfo - ): Promise> { + async makeRequestWithTokens( + requestInfo: RequestInfo, + requestFactory: () => Connection + ): Promise { const [authToken, appCheckToken] = await Promise.all([ this._getAuthToken(), this._getAppCheckToken() ]); - return this._makeRequest(requestInfo, authToken, appCheckToken); + return this._makeRequest( + requestInfo, + requestFactory, + authToken, + appCheckToken + ).getPromise(); } } diff --git a/packages/storage/src/task.ts b/packages/storage/src/task.ts index 91131f63615..62689856153 100644 --- a/packages/storage/src/task.ts +++ b/packages/storage/src/task.ts @@ -52,6 +52,7 @@ import { multipartUpload } from './implementation/requests'; import { Reference } from './reference'; +import { newTextConnection } from './platform/connection'; /** * Represents a blob being uploaded. Can be used to pause/resume/cancel the @@ -207,6 +208,7 @@ export class UploadTask { ); const createRequest = this._ref.storage._makeRequest( requestInfo, + newTextConnection, authToken, appCheckToken ); @@ -232,6 +234,7 @@ export class UploadTask { ); const statusRequest = this._ref.storage._makeRequest( requestInfo, + newTextConnection, authToken, appCheckToken ); @@ -278,6 +281,7 @@ export class UploadTask { } const uploadRequest = this._ref.storage._makeRequest( requestInfo, + newTextConnection, authToken, appCheckToken ); @@ -314,6 +318,7 @@ export class UploadTask { ); const metadataRequest = this._ref.storage._makeRequest( requestInfo, + newTextConnection, authToken, appCheckToken ); @@ -337,6 +342,7 @@ export class UploadTask { ); const multipartRequest = this._ref.storage._makeRequest( requestInfo, + newTextConnection, authToken, appCheckToken ); @@ -521,9 +527,7 @@ export class UploadTask { /** * Equivalent to calling `then(null, onRejected)`. */ - catch( - onRejected: (p1: StorageError) => T | Promise - ): Promise { + catch(onRejected: (p1: StorageError) => T | Promise): Promise { return this.then(null, onRejected); } diff --git a/packages/storage/test/browser/blob.test.ts b/packages/storage/test/browser/blob.test.ts index eafdfacc184..b6c56eafa66 100644 --- a/packages/storage/test/browser/blob.test.ts +++ b/packages/storage/test/browser/blob.test.ts @@ -15,13 +15,31 @@ * limitations under the License. */ -import { assert } from 'chai'; +import { assert, expect } from 'chai'; import * as sinon from 'sinon'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { FirebaseApp, deleteApp } from '@firebase/app'; + import { FbsBlob } from '../../src/implementation/blob'; import * as type from '../../src/implementation/type'; import * as testShared from '../unit/testshared'; +import { createApp, createStorage } from '../integration/integration.test'; +import { getBlob, ref, uploadBytes } from '../../src'; +import * as types from '../../src/public-types'; describe('Firebase Storage > Blob', () => { + let app: FirebaseApp; + let storage: types.FirebaseStorage; + + beforeEach(async () => { + app = await createApp(); + storage = createStorage(app); + }); + + afterEach(async () => { + await deleteApp(app); + }); + let stubs: sinon.SinonStub[] = []; before(() => { const definedStub = sinon.stub(type, 'isNativeBlobDefined'); @@ -47,6 +65,7 @@ describe('Firebase Storage > Blob', () => { new Uint8Array([2, 3, 4, 5]) ); }); + it('Blobs are merged with strings correctly', () => { const blob = new FbsBlob(new Uint8Array([1, 2, 3, 4])); const merged = FbsBlob.getBlob('what', blob, '\ud83d\ude0a ')!; @@ -70,4 +89,34 @@ describe('Firebase Storage > Blob', () => { assert.equal(20, concatenated!.size()); }); + + it('can get blob', async () => { + const reference = ref(storage, 'public/exp-bytes'); + await uploadBytes(reference, new Uint8Array([0, 1, 3, 128, 255])); + const blob = await getBlob(reference); + const bytes = await blob.arrayBuffer(); + expect(new Uint8Array(bytes)).to.deep.equal( + new Uint8Array([0, 1, 3, 128, 255]) + ); + }); + + it('can get the first n-bytes of a blob', async () => { + const reference = ref(storage, 'public/exp-bytes'); + await uploadBytes(reference, new Uint8Array([0, 1, 5])); + const blob = await getBlob(reference, 2); + const bytes = await blob.arrayBuffer(); + expect(new Uint8Array(bytes)).to.deep.equal(new Uint8Array([0, 1])); + }); + + it('getBlob() throws for missing file', async () => { + const reference = ref(storage, 'public/exp-bytes-missing'); + try { + await getBlob(reference); + expect.fail(); + } catch (e) { + expect(e.message).to.satisfy((v: string) => + v.match(/Object 'public\/exp-bytes-missing' does not exist/) + ); + } + }); }); diff --git a/packages/storage/test/integration/integration.test.ts b/packages/storage/test/integration/integration.test.ts index c1f015c352e..bd11b083229 100644 --- a/packages/storage/test/integration/integration.test.ts +++ b/packages/storage/test/integration/integration.test.ts @@ -29,7 +29,8 @@ import { deleteObject, getMetadata, updateMetadata, - listAll + listAll, + getBytes } from '../../src/index'; import { use, expect } from 'chai'; @@ -46,19 +47,28 @@ export const STORAGE_BUCKET = PROJECT_CONFIG.storageBucket; export const API_KEY = PROJECT_CONFIG.apiKey; export const AUTH_DOMAIN = PROJECT_CONFIG.authDomain; -describe('FirebaseStorage Integration tests', () => { +export async function createApp(): Promise { + const app = initializeApp({ + apiKey: API_KEY, + projectId: PROJECT_ID, + storageBucket: STORAGE_BUCKET, + authDomain: AUTH_DOMAIN + }); + await signInAnonymously(getAuth(app)); + return app; +} + +export function createStorage(app: FirebaseApp): types.FirebaseStorage { + return getStorage(app); +} + +describe('FirebaseStorage Exp', () => { let app: FirebaseApp; let storage: types.FirebaseStorage; beforeEach(async () => { - app = initializeApp({ - apiKey: API_KEY, - projectId: PROJECT_ID, - storageBucket: STORAGE_BUCKET, - authDomain: AUTH_DOMAIN - }); - await signInAnonymously(getAuth(app)); - storage = getStorage(app); + app = await createApp(); + storage = createStorage(app); }); afterEach(async () => { @@ -71,6 +81,34 @@ describe('FirebaseStorage Integration tests', () => { expect(snap.metadata.timeCreated).to.exist; }); + it('can get bytes', async () => { + const reference = ref(storage, 'public/exp-bytes'); + await uploadBytes(reference, new Uint8Array([0, 1, 3, 128, 255])); + const bytes = await getBytes(reference); + expect(new Uint8Array(bytes)).to.deep.equal( + new Uint8Array([0, 1, 3, 128, 255]) + ); + }); + + it('can get first n bytes', async () => { + const reference = ref(storage, 'public/exp-bytes'); + await uploadBytes(reference, new Uint8Array([0, 1, 3])); + const bytes = await getBytes(reference, 2); + expect(new Uint8Array(bytes)).to.deep.equal(new Uint8Array([0, 1])); + }); + + it('getBytes() throws for missing file', async () => { + const reference = ref(storage, 'public/exp-bytes-missing'); + try { + await getBytes(reference); + expect.fail(); + } catch (e) { + expect(e.message).to.satisfy((v: string) => + v.match(/Object 'public\/exp-bytes-missing' does not exist/) + ); + } + }); + it('can upload bytes (resumable)', async () => { const reference = ref(storage, 'public/exp-bytesresumable'); const snap = await uploadBytesResumable( diff --git a/packages/storage/test/node/stream.test.ts b/packages/storage/test/node/stream.test.ts new file mode 100644 index 00000000000..bbde160692a --- /dev/null +++ b/packages/storage/test/node/stream.test.ts @@ -0,0 +1,76 @@ +/** + * @license + * Copyright 2021 Google LLC + * + * 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. + */ + +import { expect } from 'chai'; +import { createApp, createStorage } from '../integration/integration.test'; +import { FirebaseApp, deleteApp } from '@firebase/app'; +import { getStream, ref, uploadBytes } from '../../src/index.node'; +import * as types from '../../src/public-types'; + +async function readData(reader: NodeJS.ReadableStream): Promise { + return new Promise((resolve, reject) => { + const data: number[] = []; + reader.on('error', e => reject(e)); + reader.on('data', chunk => data.push(...Array.from(chunk as Buffer))); + reader.on('end', () => resolve(data)); + }); +} + +describe('Firebase Storage > getStream', () => { + let app: FirebaseApp; + let storage: types.FirebaseStorage; + + beforeEach(async () => { + app = await createApp(); + storage = createStorage(app); + }); + + afterEach(async () => { + await deleteApp(app); + }); + + it('can get stream', async () => { + const reference = ref(storage, 'public/exp-bytes'); + await uploadBytes(reference, new Uint8Array([0, 1, 3, 128, 255])); + const stream = await getStream(reference); + const data = await readData(stream); + expect(new Uint8Array(data)).to.deep.equal( + new Uint8Array([0, 1, 3, 128, 255]) + ); + }); + + it('can get first n bytes of stream', async () => { + const reference = ref(storage, 'public/exp-bytes'); + await uploadBytes(reference, new Uint8Array([0, 1, 3])); + const stream = await getStream(reference, 2); + const data = await readData(stream); + expect(new Uint8Array(data)).to.deep.equal(new Uint8Array([0, 1])); + }); + + it('getStream() throws for missing file', async () => { + const reference = ref(storage, 'public/exp-bytes-missing'); + const stream = getStream(reference); + try { + await readData(stream); + expect.fail(); + } catch (e) { + expect(e.message).to.satisfy((v: string) => + v.match(/Object 'public\/exp-bytes-missing' does not exist/) + ); + } + }); +}); diff --git a/packages/storage/test/unit/connection.ts b/packages/storage/test/unit/connection.ts index 350feaa90bf..018f65f3671 100644 --- a/packages/storage/test/unit/connection.ts +++ b/packages/storage/test/unit/connection.ts @@ -19,10 +19,7 @@ import { Headers, Connection } from '../../src/implementation/connection'; -import { - StorageError, - StorageErrorCode -} from '../../src/implementation/error'; +import { StorageError, StorageErrorCode } from '../../src/implementation/error'; export type SendHook = ( connection: TestingConnection, @@ -38,7 +35,7 @@ export enum State { DONE = 2 } -export class TestingConnection implements Connection { +export class TestingConnection implements Connection { private state: State; private sendPromise: Promise; private resolve!: () => void; @@ -67,10 +64,7 @@ export class TestingConnection implements Connection { headers?: Headers ): Promise { if (this.state !== State.START) { - throw new StorageError( - StorageErrorCode.UNKNOWN, - "Can't send again" - ); + throw new StorageError(StorageErrorCode.UNKNOWN, "Can't send again"); } this.state = State.SENT; @@ -113,7 +107,11 @@ export class TestingConnection implements Connection { return this.status; } - getResponseText(): string { + getResponse(): string { + return this.responseText; + } + + getErrorText(): string { return this.responseText; } @@ -140,3 +138,9 @@ export class TestingConnection implements Connection { // TODO(andysoto): impl } } + +export function newTestConnection( + sendHook?: SendHook | null +): Connection { + return new TestingConnection(sendHook ?? null); +} diff --git a/packages/storage/test/unit/reference.test.ts b/packages/storage/test/unit/reference.test.ts index 66f53017f87..fe9299ad2b0 100644 --- a/packages/storage/test/unit/reference.test.ts +++ b/packages/storage/test/unit/reference.test.ts @@ -32,35 +32,29 @@ import { } from '../../src/reference'; import { FirebaseStorageImpl, ref } from '../../src/service'; import * as testShared from './testshared'; -import { SendHook, TestingConnection } from './connection'; +import { newTestConnection, TestingConnection } from './connection'; import { DEFAULT_HOST } from '../../src/implementation/constants'; import { FirebaseAuthInternalName } from '@firebase/auth-interop-types'; import { Provider } from '@firebase/component'; import { AppCheckInternalComponentName } from '@firebase/app-check-interop-types'; import { fakeServerHandler, storageServiceWithHandler } from './testshared'; import { decodeUint8Array } from '../../src/platform/base64'; +import { injectTestConnection } from '../../src/platform/connection'; /* eslint-disable @typescript-eslint/no-floating-promises */ function makeFakeService( app: FirebaseApp, authProvider: Provider, - appCheckProvider: Provider, - sendHook: SendHook + appCheckProvider: Provider ): FirebaseStorageImpl { - return new FirebaseStorageImpl( - app, - authProvider, - appCheckProvider, - testShared.makePool(sendHook) - ); + return new FirebaseStorageImpl(app, authProvider, appCheckProvider); } function makeStorage(url: string): Reference { const service = new FirebaseStorageImpl( {} as FirebaseApp, testShared.emptyAuthProvider, - testShared.fakeAppCheckTokenProvider, - testShared.makePool(null) + testShared.fakeAppCheckTokenProvider ); return new Reference(service, url); } @@ -85,14 +79,15 @@ function withFakeSend( text.then(text => { testFn(text, headers); connection.abort(); + injectTestConnection(null); resolveFn(); }); } + injectTestConnection(() => newTestConnection(newSend)); const service = makeFakeService( testShared.fakeApp, testShared.fakeAuthProvider, - testShared.fakeAppCheckTokenProvider, - newSend + testShared.fakeAppCheckTokenProvider ); return ref(service, 'gs://test-bucket'); } @@ -231,14 +226,15 @@ describe('Firebase Storage > Reference', () => { ): void { expect(headers).to.not.be.undefined; expect(headers!['Authorization']).to.be.undefined; + injectTestConnection(null); done(); } + injectTestConnection(() => newTestConnection(newSend)); const service = makeFakeService( testShared.fakeApp, testShared.emptyAuthProvider, - testShared.fakeAppCheckTokenProvider, - newSend + testShared.fakeAppCheckTokenProvider ); const reference = ref(service, 'gs://test-bucket'); getMetadata(ref(reference, 'foo')); @@ -257,14 +253,15 @@ describe('Firebase Storage > Reference', () => { expect(headers!['Authorization']).to.equal( 'Firebase ' + testShared.authToken ); + injectTestConnection(null); done(); } + injectTestConnection(() => newTestConnection(newSend)); const service = makeFakeService( testShared.fakeApp, testShared.fakeAuthProvider, - testShared.fakeAppCheckTokenProvider, - newSend + testShared.fakeAppCheckTokenProvider ); const reference = ref(service, 'gs://test-bucket'); getMetadata(ref(reference, 'foo')); @@ -325,13 +322,13 @@ describe('Firebase Storage > Reference', () => { describe('Argument verification', () => { describe('list', () => { it('throws on invalid maxResults', async () => { - await expect(list(child, { maxResults: 0 })).to.be.rejectedWith( + expect(() => list(child, { maxResults: 0 })).to.throw( 'storage/invalid-argument' ); - await expect(list(child, { maxResults: -4 })).to.be.rejectedWith( + expect(() => list(child, { maxResults: -4 })).to.throw( 'storage/invalid-argument' ); - await expect(list(child, { maxResults: 1001 })).to.be.rejectedWith( + expect(() => list(child, { maxResults: 1001 })).to.throw( 'storage/invalid-argument' ); }); @@ -355,22 +352,22 @@ describe('Firebase Storage > Reference', () => { ); }); it('deleteObject throws', async () => { - await expect(deleteObject(root)).to.be.rejectedWith( + expect(() => deleteObject(root)).to.throw( 'storage/invalid-root-operation' ); }); it('getMetadata throws', async () => { - await expect(getMetadata(root)).to.be.rejectedWith( + expect(() => getMetadata(root)).to.throw( 'storage/invalid-root-operation' ); }); it('updateMetadata throws', async () => { - await expect(updateMetadata(root, {} as Metadata)).to.be.rejectedWith( + expect(() => updateMetadata(root, {} as Metadata)).to.throw( 'storage/invalid-root-operation' ); }); it('getDownloadURL throws', async () => { - await expect(getDownloadURL(root)).to.be.rejectedWith( + expect(() => getDownloadURL(root)).to.throw( 'storage/invalid-root-operation' ); }); diff --git a/packages/storage/test/unit/request.test.ts b/packages/storage/test/unit/request.test.ts index 26304b271f8..69b3dc41e1d 100644 --- a/packages/storage/test/unit/request.test.ts +++ b/packages/storage/test/unit/request.test.ts @@ -19,8 +19,7 @@ import * as sinon from 'sinon'; import { makeRequest } from '../../src/implementation/request'; import { RequestInfo } from '../../src/implementation/requestinfo'; import { Connection } from '../../src/implementation/connection'; -import { makePool } from './testshared'; -import { TestingConnection } from './connection'; +import { TestingConnection, newTestConnection } from './connection'; const TEST_VERSION = '1.2.3'; @@ -45,7 +44,7 @@ describe('Firebase Storage > Request', () => { } const spiedSend = sinon.spy(newSend); - function handler(connection: Connection, text: string): string { + function handler(connection: Connection, text: string): string { assert.equal(text, response); assert.equal(connection.getResponseHeader(responseHeader), responseValue); assert.equal(connection.getStatus(), status); @@ -64,7 +63,7 @@ describe('Firebase Storage > Request', () => { null, null, null, - makePool(spiedSend), + () => newTestConnection(spiedSend), TEST_VERSION ) .getPromise() @@ -93,7 +92,7 @@ describe('Firebase Storage > Request', () => { } const spiedSend = sinon.spy(newSend); - function handler(connection: Connection, text: string): string { + function handler(connection: Connection, text: string): string { return text; } @@ -108,7 +107,9 @@ describe('Firebase Storage > Request', () => { requestInfo.urlParams[p1] = v1; requestInfo.urlParams[p2] = v2; requestInfo.body = 'thisistherequestbody'; - return makeRequest(requestInfo, null, null, null, makePool(spiedSend)) + return makeRequest(requestInfo, null, null, null, () => + newTestConnection(spiedSend) + ) .getPromise() .then( () => { @@ -151,7 +152,9 @@ describe('Firebase Storage > Request', () => { timeout ); - return makeRequest(requestInfo, null, null, null, makePool(newSend)) + return makeRequest(requestInfo, null, null, null, () => + newTestConnection(newSend) + ) .getPromise() .then( () => { @@ -173,7 +176,13 @@ describe('Firebase Storage > Request', () => { handler, timeout ); - const request = makeRequest(requestInfo, null, null, null, makePool(null)); + const request = makeRequest( + requestInfo, + null, + null, + null, + newTestConnection + ); const promise = request.getPromise().then( () => { assert.fail('Succeeded when handler gave error'); @@ -205,7 +214,7 @@ describe('Firebase Storage > Request', () => { /* appId= */ null, authToken, null, - makePool(spiedSend), + () => newTestConnection(spiedSend), TEST_VERSION ); return request.getPromise().then( @@ -246,7 +255,7 @@ describe('Firebase Storage > Request', () => { appId, null, null, - makePool(spiedSend), + () => newTestConnection(spiedSend), TEST_VERSION ); return request.getPromise().then( @@ -287,7 +296,7 @@ describe('Firebase Storage > Request', () => { null, null, appCheckToken, - makePool(spiedSend), + () => newTestConnection(spiedSend), TEST_VERSION ); return request.getPromise().then( diff --git a/packages/storage/test/unit/requests.test.ts b/packages/storage/test/unit/requests.test.ts index 1996a600e85..8a7cff0ca4a 100644 --- a/packages/storage/test/unit/requests.test.ts +++ b/packages/storage/test/unit/requests.test.ts @@ -32,12 +32,12 @@ import { getResumableUploadStatus, ResumableUploadStatus, continueResumableUpload, - RESUMABLE_UPLOAD_CHUNK_SIZE + RESUMABLE_UPLOAD_CHUNK_SIZE, + getBytes } from '../../src/implementation/requests'; import { makeUrl } from '../../src/implementation/url'; import { unknown, StorageErrorCode } from '../../src/implementation/error'; import { RequestInfo } from '../../src/implementation/requestinfo'; -import { ConnectionPool } from '../../src/implementation/connectionPool'; import { Metadata } from '../../src/metadata'; import { FirebaseStorageImpl } from '../../src/service'; import { @@ -82,8 +82,7 @@ describe('Firebase Storage > Requests', () => { const storageService = new FirebaseStorageImpl( mockApp, fakeAuthProvider, - fakeAppCheckTokenProvider, - new ConnectionPool() + fakeAppCheckTokenProvider ); const contentTypeInMetadata = 'application/jason'; @@ -180,12 +179,14 @@ describe('Firebase Storage > Requests', () => { } } - function checkMetadataHandler(requestInfo: RequestInfo): void { + function checkMetadataHandler( + requestInfo: RequestInfo + ): void { const metadata = requestInfo.handler(fakeXhrIo({}), serverResourceString); assert.deepEqual(metadata, metadataFromServerResource); } - function checkNoOpHandler(requestInfo: RequestInfo): void { + function checkNoOpHandler(requestInfo: RequestInfo): void { try { requestInfo.handler(fakeXhrIo({}), ''); } catch (e) { @@ -346,6 +347,14 @@ describe('Firebase Storage > Requests', () => { const url = requestInfo.handler(fakeXhrIo({}), serverResourceString); assert.equal(url, downloadUrlFromServerResource); }); + it('getBytes handler', () => { + const requestInfo = getBytes(storageService, locationNormal); + const bytes = requestInfo.handler( + fakeXhrIo({}), + new Uint8Array([1, 128, 255]) + ); + assert.deepEqual(new Uint8Array(bytes), new Uint8Array([1, 128, 255])); + }); it('updateMetadata requestinfo', () => { const maps = [ [locationNormal, locationNormalUrl], diff --git a/packages/storage/test/unit/service.test.ts b/packages/storage/test/unit/service.test.ts index 69b25eef685..be42bb8dd6e 100644 --- a/packages/storage/test/unit/service.test.ts +++ b/packages/storage/test/unit/service.test.ts @@ -17,7 +17,6 @@ import { expect } from 'chai'; import { TaskEvent } from '../../src/implementation/taskenums'; import { Headers } from '../../src/implementation/connection'; -import { ConnectionPool } from '../../src/implementation/connectionPool'; import { FirebaseStorageImpl, ref, @@ -33,12 +32,12 @@ import { getDownloadURL } from '../../src/reference'; import { Location } from '../../src/implementation/location'; -import { TestingConnection } from './connection'; +import { newTestConnection, TestingConnection } from './connection'; +import { injectTestConnection } from '../../src/platform/connection'; const fakeAppGs = testShared.makeFakeApp('gs://mybucket'); const fakeAppGsEndingSlash = testShared.makeFakeApp('gs://mybucket/'); const fakeAppInvalidGs = testShared.makeFakeApp('gs://mybucket/hello'); -const connectionPool = new ConnectionPool(); const testLocation = new Location('bucket', 'object'); function makeGsUrl(child: string = ''): string { @@ -46,12 +45,14 @@ function makeGsUrl(child: string = ''): string { } describe('Firebase Storage > Service', () => { + before(() => injectTestConnection(newTestConnection)); + after(() => injectTestConnection(null)); + describe('simple constructor', () => { const service = new FirebaseStorageImpl( testShared.fakeApp, testShared.fakeAuthProvider, - testShared.fakeAppCheckTokenProvider, - connectionPool + testShared.fakeAppCheckTokenProvider ); it('Root refs point to the right place', () => { const reference = ref(service); @@ -68,7 +69,6 @@ describe('Firebase Storage > Service', () => { testShared.fakeApp, testShared.fakeAuthProvider, testShared.fakeAppCheckTokenProvider, - connectionPool, 'gs://foo-bar.appspot.com' ); const reference = ref(service); @@ -79,7 +79,6 @@ describe('Firebase Storage > Service', () => { testShared.fakeApp, testShared.fakeAuthProvider, testShared.fakeAppCheckTokenProvider, - connectionPool, `http://${DEFAULT_HOST}/v1/b/foo-bar.appspot.com/o` ); const reference = ref(service); @@ -90,7 +89,6 @@ describe('Firebase Storage > Service', () => { testShared.fakeApp, testShared.fakeAuthProvider, testShared.fakeAppCheckTokenProvider, - connectionPool, `https://${DEFAULT_HOST}/v1/b/foo-bar.appspot.com/o` ); const reference = ref(service); @@ -102,7 +100,6 @@ describe('Firebase Storage > Service', () => { testShared.fakeApp, testShared.fakeAuthProvider, testShared.fakeAppCheckTokenProvider, - connectionPool, 'foo-bar.appspot.com' ); const reference = ref(service); @@ -113,7 +110,6 @@ describe('Firebase Storage > Service', () => { testShared.fakeApp, testShared.fakeAuthProvider, testShared.fakeAppCheckTokenProvider, - connectionPool, 'foo-bar.appspot.com' ); const reference = ref(service, 'path/to/child'); @@ -127,7 +123,6 @@ describe('Firebase Storage > Service', () => { testShared.fakeApp, testShared.fakeAuthProvider, testShared.fakeAppCheckTokenProvider, - connectionPool, 'gs://bucket/object/' ); }, 'storage/invalid-default-bucket'); @@ -139,8 +134,7 @@ describe('Firebase Storage > Service', () => { const service = new FirebaseStorageImpl( fakeAppGs, testShared.fakeAuthProvider, - testShared.fakeAppCheckTokenProvider, - connectionPool + testShared.fakeAppCheckTokenProvider ); expect(ref(service)?.toString()).to.equal('gs://mybucket/'); }); @@ -148,8 +142,7 @@ describe('Firebase Storage > Service', () => { const service = new FirebaseStorageImpl( fakeAppGsEndingSlash, testShared.fakeAuthProvider, - testShared.fakeAppCheckTokenProvider, - connectionPool + testShared.fakeAppCheckTokenProvider ); expect(ref(service)?.toString()).to.equal('gs://mybucket/'); }); @@ -158,8 +151,7 @@ describe('Firebase Storage > Service', () => { new FirebaseStorageImpl( fakeAppInvalidGs, testShared.fakeAuthProvider, - testShared.fakeAppCheckTokenProvider, - connectionPool + testShared.fakeAppCheckTokenProvider ); }, 'storage/invalid-default-bucket'); }); @@ -168,8 +160,7 @@ describe('Firebase Storage > Service', () => { const service = new FirebaseStorageImpl( testShared.fakeApp, testShared.fakeAuthProvider, - testShared.fakeAppCheckTokenProvider, - connectionPool + testShared.fakeAppCheckTokenProvider ); it('Works with gs:// URLs', () => { const reference = ref(service, 'gs://mybucket/child/path/image.png'); @@ -237,24 +228,20 @@ GOOG4-RSA-SHA256` }); describe('connectStorageEmulator(service, host, port, options)', () => { it('sets emulator host correctly', done => { - function newSend( - connection: TestingConnection, - url: string, - method: string, - body?: ArrayBufferView | Blob | string | null, - headers?: Headers - ): void { + function newSend(connection: TestingConnection, url: string): void { // Expect emulator host to be in url of storage operations requests, // in this case getDownloadURL. expect(url).to.match(/^http:\/\/test\.host\.org:1234.+/); connection.abort(); + injectTestConnection(null); done(); } + + injectTestConnection(() => newTestConnection(newSend)); const service = new FirebaseStorageImpl( testShared.fakeApp, testShared.fakeAuthProvider, - testShared.fakeAppCheckTokenProvider, - testShared.makePool(newSend) + testShared.fakeAppCheckTokenProvider ); connectStorageEmulator(service, 'test.host.org', 1234); expect(service.host).to.equal('test.host.org:1234'); @@ -275,13 +262,14 @@ GOOG4-RSA-SHA256` expect(url).to.match(/^http:\/\/test\.host\.org:1234.+/); expect(headers?.['Authorization']).to.eql(`Firebase ${mockUserToken}`); connection.abort(); + injectTestConnection(null); done(); } + injectTestConnection(() => newTestConnection(newSend)); const service = new FirebaseStorageImpl( testShared.fakeApp, testShared.fakeAuthProvider, - testShared.fakeAppCheckTokenProvider, - testShared.makePool(newSend) + testShared.fakeAppCheckTokenProvider ); connectStorageEmulator(service, 'test.host.org', 1234, { mockUserToken }); expect(service.host).to.equal('test.host.org:1234'); @@ -303,14 +291,14 @@ GOOG4-RSA-SHA256` expect(url).to.match(/^http:\/\/test\.host\.org:1234.+/); expect(headers?.['Authorization']).to.eql(`Firebase ${token}`); connection.abort(); + injectTestConnection(null); done(); } - + injectTestConnection(() => newTestConnection(newSend)); const service = new FirebaseStorageImpl( testShared.fakeApp, testShared.fakeAuthProvider, - testShared.fakeAppCheckTokenProvider, - testShared.makePool(newSend) + testShared.fakeAppCheckTokenProvider ); connectStorageEmulator(service, 'test.host.org', 1234, { mockUserToken: { sub: 'alice' } @@ -327,8 +315,7 @@ GOOG4-RSA-SHA256` const service = new FirebaseStorageImpl( testShared.fakeApp, testShared.fakeAuthProvider, - testShared.fakeAppCheckTokenProvider, - connectionPool + testShared.fakeAppCheckTokenProvider ); it('Works with non URL paths', () => { const newRef = ref(service, 'child/path/image.png'); @@ -343,8 +330,7 @@ GOOG4-RSA-SHA256` const service = new FirebaseStorageImpl( testShared.fakeApp, testShared.fakeAuthProvider, - testShared.fakeAppCheckTokenProvider, - connectionPool + testShared.fakeAppCheckTokenProvider ); const reference = new Reference(service, testLocation); it('Throws calling ref(reference, path) with a gs:// URL', () => { @@ -381,8 +367,7 @@ GOOG4-RSA-SHA256` const service = new FirebaseStorageImpl( testShared.fakeApp, testShared.fakeAuthProvider, - testShared.fakeAppCheckTokenProvider, - connectionPool + testShared.fakeAppCheckTokenProvider ); it('In-flight requests are canceled when the service is deleted', async () => { const reference = ref(service, 'gs://mybucket/image.jpg'); @@ -424,8 +409,7 @@ GOOG4-RSA-SHA256` const service = new FirebaseStorageImpl( testShared.fakeApp, testShared.fakeAuthProvider, - testShared.fakeAppCheckTokenProvider, - connectionPool + testShared.fakeAppCheckTokenProvider ); describe('setMaxUploadRetryTime', () => { it('Throws on negative arg', () => { diff --git a/packages/storage/test/unit/string.test.ts b/packages/storage/test/unit/string.test.ts index 5e445f9ccb1..3bc35e166e9 100644 --- a/packages/storage/test/unit/string.test.ts +++ b/packages/storage/test/unit/string.test.ts @@ -23,20 +23,8 @@ describe('Firebase Storage > String', () => { const str = 'Hello, world!\n'; assertUint8ArrayEquals( new Uint8Array([ - 0x48, - 0x65, - 0x6c, - 0x6c, - 0x6f, - 0x2c, - 0x20, - 0x77, - 0x6f, - 0x72, - 0x6c, - 0x64, - 0x21, - 0x0a + 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x2c, 0x20, 0x77, 0x6f, 0x72, 0x6c, 0x64, + 0x21, 0x0a ]), dataFromString(StringFormat.RAW, str).data ); @@ -59,17 +47,7 @@ describe('Firebase Storage > String', () => { const str = 'Hello! \ud83d\ude0a'; assertUint8ArrayEquals( new Uint8Array([ - 0x48, - 0x65, - 0x6c, - 0x6c, - 0x6f, - 0x21, - 0x20, - 0xf0, - 0x9f, - 0x98, - 0x8a + 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x21, 0x20, 0xf0, 0x9f, 0x98, 0x8a ]), dataFromString(StringFormat.RAW, str).data ); @@ -91,26 +69,8 @@ describe('Firebase Storage > String', () => { it('Encodes base64 strings correctly', () => { const str = 'CpYlM1+XsGxTd1n6izHMU/yY3Bw='; const base64Bytes = new Uint8Array([ - 0x0a, - 0x96, - 0x25, - 0x33, - 0x5f, - 0x97, - 0xb0, - 0x6c, - 0x53, - 0x77, - 0x59, - 0xfa, - 0x8b, - 0x31, - 0xcc, - 0x53, - 0xfc, - 0x98, - 0xdc, - 0x1c + 0x0a, 0x96, 0x25, 0x33, 0x5f, 0x97, 0xb0, 0x6c, 0x53, 0x77, 0x59, 0xfa, + 0x8b, 0x31, 0xcc, 0x53, 0xfc, 0x98, 0xdc, 0x1c ]); assertUint8ArrayEquals( base64Bytes, @@ -120,26 +80,8 @@ describe('Firebase Storage > String', () => { it('Encodes base64 strings without padding correctly', () => { const str = 'CpYlM1+XsGxTd1n6izHMU/yY3Bw'; const base64Bytes = new Uint8Array([ - 0x0a, - 0x96, - 0x25, - 0x33, - 0x5f, - 0x97, - 0xb0, - 0x6c, - 0x53, - 0x77, - 0x59, - 0xfa, - 0x8b, - 0x31, - 0xcc, - 0x53, - 0xfc, - 0x98, - 0xdc, - 0x1c + 0x0a, 0x96, 0x25, 0x33, 0x5f, 0x97, 0xb0, 0x6c, 0x53, 0x77, 0x59, 0xfa, + 0x8b, 0x31, 0xcc, 0x53, 0xfc, 0x98, 0xdc, 0x1c ]); assertUint8ArrayEquals( base64Bytes, @@ -155,26 +97,8 @@ describe('Firebase Storage > String', () => { it('Encodes base64url strings correctly', () => { const str = 'CpYlM1-XsGxTd1n6izHMU_yY3Bw='; const base64Bytes = new Uint8Array([ - 0x0a, - 0x96, - 0x25, - 0x33, - 0x5f, - 0x97, - 0xb0, - 0x6c, - 0x53, - 0x77, - 0x59, - 0xfa, - 0x8b, - 0x31, - 0xcc, - 0x53, - 0xfc, - 0x98, - 0xdc, - 0x1c + 0x0a, 0x96, 0x25, 0x33, 0x5f, 0x97, 0xb0, 0x6c, 0x53, 0x77, 0x59, 0xfa, + 0x8b, 0x31, 0xcc, 0x53, 0xfc, 0x98, 0xdc, 0x1c ]); assertUint8ArrayEquals( base64Bytes, @@ -184,26 +108,8 @@ describe('Firebase Storage > String', () => { it('Encodes base64url strings without padding correctly', () => { const str = 'CpYlM1-XsGxTd1n6izHMU_yY3Bw'; const base64Bytes = new Uint8Array([ - 0x0a, - 0x96, - 0x25, - 0x33, - 0x5f, - 0x97, - 0xb0, - 0x6c, - 0x53, - 0x77, - 0x59, - 0xfa, - 0x8b, - 0x31, - 0xcc, - 0x53, - 0xfc, - 0x98, - 0xdc, - 0x1c + 0x0a, 0x96, 0x25, 0x33, 0x5f, 0x97, 0xb0, 0x6c, 0x53, 0x77, 0x59, 0xfa, + 0x8b, 0x31, 0xcc, 0x53, 0xfc, 0x98, 0xdc, 0x1c ]); assertUint8ArrayEquals( base64Bytes, @@ -253,16 +159,7 @@ describe('Firebase Storage > String', () => { const data = dataFromString(StringFormat.DATA_URL, str); assertUint8ArrayEquals( new Uint8Array([ - 0xf0, - 0x9f, - 0x98, - 0x8a, - 0xe6, - 0x86, - 0x82, - 0xe9, - 0xac, - 0xb1 + 0xf0, 0x9f, 0x98, 0x8a, 0xe6, 0x86, 0x82, 0xe9, 0xac, 0xb1 ]), data.data ); diff --git a/packages/storage/test/unit/task.test.ts b/packages/storage/test/unit/task.test.ts index 93a3d4b9620..cdd57152cb0 100644 --- a/packages/storage/test/unit/task.test.ts +++ b/packages/storage/test/unit/task.test.ts @@ -22,12 +22,15 @@ import { TaskEvent, TaskState } from '../../src/implementation/taskenums'; import { Reference } from '../../src/reference'; import { UploadTask } from '../../src/task'; import { fakeServerHandler, storageServiceWithHandler } from './testshared'; +import { injectTestConnection } from '../../src/platform/connection'; const testLocation = new Location('bucket', 'object'); const smallBlob = new FbsBlob(new Uint8Array([97])); const bigBlob = new FbsBlob(new ArrayBuffer(1024 * 1024)); describe('Firebase Storage > Upload Task', () => { + after(() => injectTestConnection(null)); + it('Works for a small upload w/ an observer', done => { const storageService = storageServiceWithHandler(fakeServerHandler()); const task = new UploadTask( diff --git a/packages/storage/test/unit/testshared.ts b/packages/storage/test/unit/testshared.ts index b0c368e87d4..91871a1748e 100644 --- a/packages/storage/test/unit/testshared.ts +++ b/packages/storage/test/unit/testshared.ts @@ -23,8 +23,7 @@ import { FirebaseApp } from '@firebase/app-types'; import { CONFIG_STORAGE_BUCKET_KEY } from '../../src/implementation/constants'; import { StorageError } from '../../src/implementation/error'; import { Headers, Connection } from '../../src/implementation/connection'; -import { ConnectionPool } from '../../src/implementation/connectionPool'; -import { SendHook, TestingConnection } from './connection'; +import { newTestConnection, TestingConnection } from './connection'; import { FirebaseAuthInternalName } from '@firebase/auth-interop-types'; import { Provider, @@ -35,6 +34,7 @@ import { import { AppCheckInternalComponentName } from '@firebase/app-check-interop-types'; import { FirebaseStorageImpl } from '../../src/service'; import { Metadata } from '../../src/metadata'; +import { injectTestConnection } from '../../src/platform/connection'; export const authToken = 'totally-legit-auth-token'; export const appCheckToken = 'totally-shady-token'; @@ -106,20 +106,14 @@ export function makeFakeAppCheckProvider(tokenResult: { return provider as Provider; } -export function makePool(sendHook: SendHook | null): ConnectionPool { - const pool: any = { - createConnection() { - return new TestingConnection(sendHook); - } - }; - return pool as ConnectionPool; -} - /** * Returns something that looks like an fbs.XhrIo with the given headers * and status. */ -export function fakeXhrIo(headers: Headers, status: number = 200): Connection { +export function fakeXhrIo( + headers: Headers, + status: number = 200 +): Connection { const lower: Headers = {}; for (const [key, value] of Object.entries(headers)) { lower[key.toLowerCase()] = value.toString(); @@ -139,7 +133,7 @@ export function fakeXhrIo(headers: Headers, status: number = 200): Connection { } }; - return fakeConnection as Connection; + return fakeConnection as Connection; } /** @@ -152,10 +146,7 @@ export function bind(f: Function, ctx: any, ...args: any[]): () => void { }; } -export function assertThrows( - f: () => void, - code: string -): StorageError { +export function assertThrows(f: () => void, code: string): StorageError { let captured: StorageError | null = null; expect(() => { try { @@ -227,11 +218,11 @@ export function storageServiceWithHandler( ); } + injectTestConnection(() => newTestConnection(newSend)); return new FirebaseStorageImpl( {} as FirebaseApp, emptyAuthProvider, - fakeAppCheckTokenProvider, - makePool(newSend) + fakeAppCheckTokenProvider ); }