diff --git a/.changeset/shy-dragons-fly.md b/.changeset/shy-dragons-fly.md new file mode 100644 index 00000000000..8379e8ab50f --- /dev/null +++ b/.changeset/shy-dragons-fly.md @@ -0,0 +1,9 @@ +--- +"@firebase/database": patch +"@firebase/firestore": patch +"@firebase/functions": patch +"@firebase/storage": patch +"@firebase/util": patch +--- + +Handle IPv6 addresses in emulator autoinit. diff --git a/common/api-review/util.api.md b/common/api-review/util.api.md index 57e18bd3d2b..eacd4ef3aa8 100644 --- a/common/api-review/util.api.md +++ b/common/api-review/util.api.md @@ -218,6 +218,9 @@ export const getDefaultAppConfig: () => Record | undefined; // @public export const getDefaultEmulatorHost: (productName: string) => string | undefined; +// @public +export const getDefaultEmulatorHostnameAndPort: (productName: string) => [hostname: string, port: number] | undefined; + // @public export const getExperimentalSetting: (name: T) => FirebaseDefaults[`_${T}`]; diff --git a/packages/database/src/api/Database.ts b/packages/database/src/api/Database.ts index db0273b1721..6c367a87c44 100644 --- a/packages/database/src/api/Database.ts +++ b/packages/database/src/api/Database.ts @@ -28,7 +28,7 @@ import { getModularInstance, createMockUserToken, EmulatorMockTokenOptions, - getDefaultEmulatorHost + getDefaultEmulatorHostnameAndPort } from '@firebase/util'; import { AppCheckTokenProvider } from '../core/AppCheckTokenProvider'; @@ -320,10 +320,9 @@ export function getDatabase( const db = _getProvider(app, 'database').getImmediate({ identifier: url }) as Database; - const databaseEmulatorHost = getDefaultEmulatorHost('database'); - if (databaseEmulatorHost) { - const [host, port] = databaseEmulatorHost.split(':'); - connectDatabaseEmulator(db, host, parseInt(port, 10)); + const emulator = getDefaultEmulatorHostnameAndPort('database'); + if (emulator) { + connectDatabaseEmulator(db, ...emulator); } return db; } diff --git a/packages/firestore/src/api/database.ts b/packages/firestore/src/api/database.ts index d15879b620e..29cca7e68ca 100644 --- a/packages/firestore/src/api/database.ts +++ b/packages/firestore/src/api/database.ts @@ -21,7 +21,7 @@ import { FirebaseApp, getApp } from '@firebase/app'; -import { deepEqual, getDefaultEmulatorHost } from '@firebase/util'; +import { deepEqual, getDefaultEmulatorHostnameAndPort } from '@firebase/util'; import { User } from '../auth/user'; import { @@ -242,10 +242,9 @@ export function getFirestore( identifier: databaseId }) as Firestore; if (!db._initialized) { - const firestoreEmulatorHost = getDefaultEmulatorHost('firestore'); - if (firestoreEmulatorHost) { - const [host, port] = firestoreEmulatorHost.split(':'); - connectFirestoreEmulator(db, host, parseInt(port, 10)); + const emulator = getDefaultEmulatorHostnameAndPort('firestore'); + if (emulator) { + connectFirestoreEmulator(db, ...emulator); } } return db; diff --git a/packages/firestore/src/lite-api/database.ts b/packages/firestore/src/lite-api/database.ts index e9b51c49ad4..848973873f1 100644 --- a/packages/firestore/src/lite-api/database.ts +++ b/packages/firestore/src/lite-api/database.ts @@ -25,7 +25,7 @@ import { import { createMockUserToken, EmulatorMockTokenOptions, - getDefaultEmulatorHost + getDefaultEmulatorHostnameAndPort } from '@firebase/util'; import { @@ -270,10 +270,9 @@ export function getFirestore( identifier: databaseId }) as Firestore; if (!db._initialized) { - const firestoreEmulatorHost = getDefaultEmulatorHost('firestore'); - if (firestoreEmulatorHost) { - const [host, port] = firestoreEmulatorHost.split(':'); - connectFirestoreEmulator(db, host, parseInt(port, 10)); + const emulator = getDefaultEmulatorHostnameAndPort('firestore'); + if (emulator) { + connectFirestoreEmulator(db, ...emulator); } } return db; diff --git a/packages/functions/src/api.ts b/packages/functions/src/api.ts index f6b5066b9a8..a0e529ef671 100644 --- a/packages/functions/src/api.ts +++ b/packages/functions/src/api.ts @@ -27,7 +27,10 @@ import { httpsCallable as _httpsCallable, httpsCallableFromURL as _httpsCallableFromURL } from './service'; -import { getModularInstance, getDefaultEmulatorHost } from '@firebase/util'; +import { + getModularInstance, + getDefaultEmulatorHostnameAndPort +} from '@firebase/util'; export * from './public-types'; @@ -51,11 +54,9 @@ export function getFunctions( const functionsInstance = functionsProvider.getImmediate({ identifier: regionOrCustomDomain }); - const functionsEmulatorHost = getDefaultEmulatorHost('functions'); - if (functionsEmulatorHost) { - const [host, port] = functionsEmulatorHost.split(':'); - // eslint-disable-next-line no-restricted-globals - connectFunctionsEmulator(functionsInstance, host, parseInt(port, 10)); + const emulator = getDefaultEmulatorHostnameAndPort('functions'); + if (emulator) { + connectFunctionsEmulator(functionsInstance, ...emulator); } return functionsInstance; } diff --git a/packages/storage/src/api.ts b/packages/storage/src/api.ts index 4b59c310543..301ca6cab98 100644 --- a/packages/storage/src/api.ts +++ b/packages/storage/src/api.ts @@ -53,7 +53,7 @@ import { STORAGE_TYPE } from './constants'; import { EmulatorMockTokenOptions, getModularInstance, - getDefaultEmulatorHost + getDefaultEmulatorHostnameAndPort } from '@firebase/util'; import { StringFormat } from './implementation/string'; @@ -334,11 +334,9 @@ export function getStorage( const storageInstance = storageProvider.getImmediate({ identifier: bucketUrl }); - const storageEmulatorHost = getDefaultEmulatorHost('storage'); - if (storageEmulatorHost) { - const [host, port] = storageEmulatorHost.split(':'); - // eslint-disable-next-line no-restricted-globals - connectStorageEmulator(storageInstance, host, parseInt(port, 10)); + const emulator = getDefaultEmulatorHostnameAndPort('storage'); + if (emulator) { + connectStorageEmulator(storageInstance, ...emulator); } return storageInstance; } diff --git a/packages/util/src/defaults.ts b/packages/util/src/defaults.ts index 185269dc3d4..9f4aaf87f4f 100644 --- a/packages/util/src/defaults.ts +++ b/packages/util/src/defaults.ts @@ -89,12 +89,40 @@ const getDefaults = (): FirebaseDefaults | undefined => /** * Returns emulator host stored in the __FIREBASE_DEFAULTS__ object * for the given product. + * @returns a URL host formatted like `127.0.0.1:9999` or `[::1]:4000` if available * @public */ export const getDefaultEmulatorHost = ( productName: string ): string | undefined => getDefaults()?.emulatorHosts?.[productName]; +/** + * Returns emulator hostname and port stored in the __FIREBASE_DEFAULTS__ object + * for the given product. + * @returns a pair of hostname and port like `["::1", 4000]` if available + * @public + */ +export const getDefaultEmulatorHostnameAndPort = ( + productName: string +): [hostname: string, port: number] | undefined => { + const host = getDefaultEmulatorHost(productName); + if (!host) { + return undefined; + } + const separatorIndex = host.lastIndexOf(':'); // Finding the last since IPv6 addr also has colons. + if (separatorIndex <= 0 || separatorIndex + 1 === host.length) { + throw new Error(`Invalid host ${host} with no separate hostname and port!`); + } + // eslint-disable-next-line no-restricted-globals + const port = parseInt(host.substring(separatorIndex + 1), 10); + if (host[0] === '[') { + // Bracket-quoted `[ipv6addr]:port` => return "ipv6addr" (without brackets). + return [host.substring(1, separatorIndex - 1), port]; + } else { + return [host.substring(0, separatorIndex), port]; + } +}; + /** * Returns Firebase app config stored in the __FIREBASE_DEFAULTS__ object. * @public diff --git a/packages/util/test/defaults.test.ts b/packages/util/test/defaults.test.ts new file mode 100644 index 00000000000..a1360685d7c --- /dev/null +++ b/packages/util/test/defaults.test.ts @@ -0,0 +1,138 @@ +/** + * @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 { expect } from 'chai'; +import { + getDefaultEmulatorHost, + getDefaultEmulatorHostnameAndPort +} from '../src/defaults'; +import { getGlobal } from '../src/environment'; + +describe('getDefaultEmulatorHost', () => { + after(() => { + delete getGlobal().__FIREBASE_DEFAULTS__; + }); + + context('with no config', () => { + it('returns undefined', () => { + expect(getDefaultEmulatorHost('firestore')).to.be.undefined; + }); + }); + + context('with global config not listing the emulator', () => { + before(() => { + getGlobal().__FIREBASE_DEFAULTS__ = { + emulatorHosts: { + /* no firestore */ + database: '127.0.0.1:8080' + } + }; + }); + + it('returns undefined', () => { + expect(getDefaultEmulatorHost('firestore')).to.be.undefined; + }); + }); + + context('with IPv4 hostname in global config', () => { + before(() => { + getGlobal().__FIREBASE_DEFAULTS__ = { + emulatorHosts: { + firestore: '127.0.0.1:8080' + } + }; + }); + + it('returns host', () => { + expect(getDefaultEmulatorHost('firestore')).to.equal('127.0.0.1:8080'); + }); + }); + + context('with quoted IPv6 hostname in global config', () => { + before(() => { + getGlobal().__FIREBASE_DEFAULTS__ = { + emulatorHosts: { + firestore: '[::1]:8080' + } + }; + }); + + it('returns host', () => { + expect(getDefaultEmulatorHost('firestore')).to.equal('[::1]:8080'); + }); + }); +}); + +describe('getDefaultEmulatorHostnameAndPort', () => { + after(() => { + delete getGlobal().__FIREBASE_DEFAULTS__; + }); + + context('with no config', () => { + it('returns undefined', () => { + expect(getDefaultEmulatorHostnameAndPort('firestore')).to.be.undefined; + }); + }); + + context('with global config not listing the emulator', () => { + before(() => { + getGlobal().__FIREBASE_DEFAULTS__ = { + emulatorHosts: { + /* no firestore */ + database: '127.0.0.1:8080' + } + }; + }); + + it('returns undefined', () => { + expect(getDefaultEmulatorHostnameAndPort('firestore')).to.be.undefined; + }); + }); + + context('with IPv4 hostname in global config', () => { + before(() => { + getGlobal().__FIREBASE_DEFAULTS__ = { + emulatorHosts: { + firestore: '127.0.0.1:8080' + } + }; + }); + + it('returns hostname and port splitted', () => { + expect(getDefaultEmulatorHostnameAndPort('firestore')).to.eql([ + '127.0.0.1', + 8080 + ]); + }); + }); + + context('with quoted IPv6 hostname in global config', () => { + before(() => { + getGlobal().__FIREBASE_DEFAULTS__ = { + emulatorHosts: { + firestore: '[::1]:8080' + } + }; + }); + + it('returns unquoted hostname and port splitted', () => { + expect(getDefaultEmulatorHostnameAndPort('firestore')).to.eql([ + '::1', + 8080 + ]); + }); + }); +});