diff --git a/.changeset/young-hornets-rescue.md b/.changeset/young-hornets-rescue.md new file mode 100644 index 00000000000..b6aba80702c --- /dev/null +++ b/.changeset/young-hornets-rescue.md @@ -0,0 +1,6 @@ +--- +'@firebase/util': minor +'firebase': minor +--- + +Allow users to specify their environment as `node` or `browser` to override Firebase's runtime environment detection and force the SDK to act as if it were in the respective environment. diff --git a/common/api-review/util.api.md b/common/api-review/util.api.md index eacd4ef3aa8..be8703adf3f 100644 --- a/common/api-review/util.api.md +++ b/common/api-review/util.api.md @@ -193,6 +193,7 @@ export interface FirebaseDefaults { config?: Record; // (undocumented) emulatorHosts?: Record; + forceEnvironment?: 'browser' | 'node'; } // Warning: (ae-missing-release-tag) "FirebaseError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -221,11 +222,12 @@ export const getDefaultEmulatorHost: (productName: string) => string | undefined // @public export const getDefaultEmulatorHostnameAndPort: (productName: string) => [hostname: string, port: number] | undefined; +// @public +export const getDefaults: () => FirebaseDefaults | undefined; + // @public export const getExperimentalSetting: (name: T) => FirebaseDefaults[`_${T}`]; -// Warning: (ae-missing-release-tag) "getGlobal" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function getGlobal(): typeof globalThis; diff --git a/packages/util/index.node.ts b/packages/util/index.node.ts index d63584284ac..9c3b54b1c86 100644 --- a/packages/util/index.node.ts +++ b/packages/util/index.node.ts @@ -42,3 +42,4 @@ export * from './src/uuid'; export * from './src/exponential_backoff'; export * from './src/formatters'; export * from './src/compat'; +export * from './src/global'; diff --git a/packages/util/index.ts b/packages/util/index.ts index c529580b24e..38b944cd9b5 100644 --- a/packages/util/index.ts +++ b/packages/util/index.ts @@ -37,3 +37,4 @@ export * from './src/uuid'; export * from './src/exponential_backoff'; export * from './src/formatters'; export * from './src/compat'; +export * from './src/global'; diff --git a/packages/util/src/defaults.ts b/packages/util/src/defaults.ts index ceb73c683a8..2c972604663 100644 --- a/packages/util/src/defaults.ts +++ b/packages/util/src/defaults.ts @@ -16,7 +16,7 @@ */ import { base64Decode } from './crypt'; -import { getGlobal } from './environment'; +import { getGlobal } from './global'; /** * Keys for experimental properties on the `FirebaseDefaults` object. @@ -39,6 +39,11 @@ export interface FirebaseDefaults { emulatorHosts?: Record; _authTokenSyncURL?: string; _authIdTokenMaxAge?: number; + /** + * Override Firebase's runtime environment detection and + * force the SDK to act as if it were in the specified environment. + */ + forceEnvironment?: 'browser' | 'node'; [key: string]: unknown; } @@ -90,8 +95,9 @@ const getDefaultsFromCookie = (): FirebaseDefaults | undefined => { * (1) if such an object exists as a property of `globalThis` * (2) if such an object was provided on a shell environment variable * (3) if such an object exists in a cookie + * @public */ -const getDefaults = (): FirebaseDefaults | undefined => { +export const getDefaults = (): FirebaseDefaults | undefined => { try { return ( getDefaultsFromGlobal() || diff --git a/packages/util/src/environment.ts b/packages/util/src/environment.ts index 5c9be0d26c0..04bbd92165d 100644 --- a/packages/util/src/environment.ts +++ b/packages/util/src/environment.ts @@ -16,6 +16,7 @@ */ import { CONSTANTS } from './constants'; +import { getDefaults } from './defaults'; /** * Returns navigator.userAgent string or '' if it's not defined. @@ -52,10 +53,17 @@ export function isMobileCordova(): boolean { /** * Detect Node.js. * - * @return true if Node.js environment is detected. + * @return true if Node.js environment is detected or specified. */ // Node detection logic from: https://github.com/iliakan/detect-node/ export function isNode(): boolean { + const forceEnvironment = getDefaults()?.forceEnvironment; + if (forceEnvironment === 'node') { + return true; + } else if (forceEnvironment === 'browser') { + return false; + } + try { return ( Object.prototype.toString.call(global.process) === '[object process]' @@ -193,20 +201,3 @@ export function areCookiesEnabled(): boolean { } return true; } - -/** - * Polyfill for `globalThis` object. - * @returns the `globalThis` object for the given environment. - */ -export function getGlobal(): typeof globalThis { - if (typeof self !== 'undefined') { - return self; - } - if (typeof window !== 'undefined') { - return window; - } - if (typeof global !== 'undefined') { - return global; - } - throw new Error('Unable to locate global object.'); -} diff --git a/packages/util/src/global.ts b/packages/util/src/global.ts new file mode 100644 index 00000000000..88a31efbd19 --- /dev/null +++ b/packages/util/src/global.ts @@ -0,0 +1,34 @@ +/** + * @license + * Copyright 2022 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. + */ + +/** + * Polyfill for `globalThis` object. + * @returns the `globalThis` object for the given environment. + * @public + */ +export function getGlobal(): typeof globalThis { + if (typeof self !== 'undefined') { + return self; + } + if (typeof window !== 'undefined') { + return window; + } + if (typeof global !== 'undefined') { + return global; + } + throw new Error('Unable to locate global object.'); +} diff --git a/packages/util/test/defaults.test.ts b/packages/util/test/defaults.test.ts index 05fcf883482..a5780096b30 100644 --- a/packages/util/test/defaults.test.ts +++ b/packages/util/test/defaults.test.ts @@ -21,13 +21,13 @@ import { getDefaultEmulatorHost, getDefaultEmulatorHostnameAndPort } from '../src/defaults'; -import * as environment from '../src/environment'; +import * as global from '../src/global'; use(sinonChai); describe('getDefaultEmulatorHost', () => { after(() => { - delete environment.getGlobal().__FIREBASE_DEFAULTS__; + delete global.getGlobal().__FIREBASE_DEFAULTS__; }); context('with no config', () => { @@ -68,7 +68,7 @@ describe('getDefaultEmulatorHost', () => { context('with no config and something unexpected throws', () => { let consoleInfoStub: SinonStub; before(() => { - stub(environment, 'getGlobal').throws(new Error('getGlobal threw!')); + stub(global, 'getGlobal').throws(new Error('getGlobal threw!')); consoleInfoStub = stub(console, 'info'); }); after(() => { @@ -83,7 +83,7 @@ describe('getDefaultEmulatorHost', () => { context('with global config not listing the emulator', () => { before(() => { - environment.getGlobal().__FIREBASE_DEFAULTS__ = { + global.getGlobal().__FIREBASE_DEFAULTS__ = { emulatorHosts: { /* no firestore */ database: '127.0.0.1:8080' @@ -98,7 +98,7 @@ describe('getDefaultEmulatorHost', () => { context('with IPv4 hostname in global config', () => { before(() => { - environment.getGlobal().__FIREBASE_DEFAULTS__ = { + global.getGlobal().__FIREBASE_DEFAULTS__ = { emulatorHosts: { firestore: '127.0.0.1:8080' } @@ -112,7 +112,7 @@ describe('getDefaultEmulatorHost', () => { context('with quoted IPv6 hostname in global config', () => { before(() => { - environment.getGlobal().__FIREBASE_DEFAULTS__ = { + global.getGlobal().__FIREBASE_DEFAULTS__ = { emulatorHosts: { firestore: '[::1]:8080' } @@ -127,7 +127,7 @@ describe('getDefaultEmulatorHost', () => { describe('getDefaultEmulatorHostnameAndPort', () => { after(() => { - delete environment.getGlobal().__FIREBASE_DEFAULTS__; + delete global.getGlobal().__FIREBASE_DEFAULTS__; }); context('with no config', () => { @@ -138,7 +138,7 @@ describe('getDefaultEmulatorHostnameAndPort', () => { context('with global config not listing the emulator', () => { before(() => { - environment.getGlobal().__FIREBASE_DEFAULTS__ = { + global.getGlobal().__FIREBASE_DEFAULTS__ = { emulatorHosts: { /* no firestore */ database: '127.0.0.1:8080' @@ -153,7 +153,7 @@ describe('getDefaultEmulatorHostnameAndPort', () => { context('with IPv4 hostname in global config', () => { before(() => { - environment.getGlobal().__FIREBASE_DEFAULTS__ = { + global.getGlobal().__FIREBASE_DEFAULTS__ = { emulatorHosts: { firestore: '127.0.0.1:8080' } @@ -170,7 +170,7 @@ describe('getDefaultEmulatorHostnameAndPort', () => { context('with quoted IPv6 hostname in global config', () => { before(() => { - environment.getGlobal().__FIREBASE_DEFAULTS__ = { + global.getGlobal().__FIREBASE_DEFAULTS__ = { emulatorHosts: { firestore: '[::1]:8080' } diff --git a/packages/util/test/environments.test.ts b/packages/util/test/environments.test.ts new file mode 100644 index 00000000000..0abc12ea633 --- /dev/null +++ b/packages/util/test/environments.test.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2022 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 { isNode } from '../src/environment'; +import { SinonStub, stub, restore } from 'sinon'; +import * as defaults from '../src/defaults'; + +const firebaseDefaults: defaults.FirebaseDefaults = { + _authTokenSyncURL: 'string', + _authIdTokenMaxAge: 200, + forceEnvironment: 'node' +}; + +describe('isNode()', () => { + let getDefaultsFromGlobalStub: SinonStub; + + beforeEach(async () => { + getDefaultsFromGlobalStub = stub(defaults, 'getDefaults'); + }); + + afterEach(async () => { + restore(); + }); + + it('returns true if forceEnvironment lists `node`', () => { + getDefaultsFromGlobalStub.returns(firebaseDefaults); + + expect(isNode()).to.be.true; + }); + + it('returns false if forceEnvironment lists `browser`', () => { + getDefaultsFromGlobalStub.returns({ + ...firebaseDefaults, + forceEnvironment: 'browser' + }); + + expect(isNode()).to.be.false; + }); +});