From ef33328f7cb7d585a1304ed39649f5b69a111b3c Mon Sep 17 00:00:00 2001 From: Sam Stern Date: Tue, 20 Oct 2020 14:44:20 -0400 Subject: [PATCH] Implement useEmulator for Database (#3904) --- .changeset/bright-ducks-jump.md | 7 ++ packages/database-types/index.d.ts | 2 + packages/database/src/api/Database.ts | 89 +++++++++++++++-------- packages/database/src/core/Repo.ts | 28 ++++--- packages/database/src/core/RepoManager.ts | 23 +++++- packages/database/test/database.test.ts | 30 +++++++- packages/firebase/index.d.ts | 9 +++ 7 files changed, 144 insertions(+), 44 deletions(-) create mode 100644 .changeset/bright-ducks-jump.md diff --git a/.changeset/bright-ducks-jump.md b/.changeset/bright-ducks-jump.md new file mode 100644 index 00000000000..c864dd3a086 --- /dev/null +++ b/.changeset/bright-ducks-jump.md @@ -0,0 +1,7 @@ +--- +'firebase': minor +'@firebase/database': minor +'@firebase/database-types': minor +--- + +Add a useEmulator(host, port) method to Realtime Database diff --git a/packages/database-types/index.d.ts b/packages/database-types/index.d.ts index 134688d31cf..15538ce1213 100644 --- a/packages/database-types/index.d.ts +++ b/packages/database-types/index.d.ts @@ -34,6 +34,7 @@ export interface DataSnapshot { export interface Database { app: FirebaseApp; + useEmulator(host: string, port: number): void; goOffline(): void; goOnline(): void; ref(path?: string | Reference): Reference; @@ -43,6 +44,7 @@ export interface Database { export class FirebaseDatabase implements Database { private constructor(); app: FirebaseApp; + useEmulator(host: string, port: number): void; goOffline(): void; goOnline(): void; ref(path?: string | Reference): Reference; diff --git a/packages/database/src/api/Database.ts b/packages/database/src/api/Database.ts index 59cc873d3d1..b75e09cdced 100644 --- a/packages/database/src/api/Database.ts +++ b/packages/database/src/api/Database.ts @@ -33,8 +33,11 @@ import { FirebaseDatabase } from '@firebase/database-types'; * @implements {FirebaseService} */ export class Database implements FirebaseService { - INTERNAL: DatabaseInternals; - private root_: Reference; + /** Track if the instance has been used (root or repo accessed) */ + private instanceStarted_: boolean = false; + + /** Backing state for root_ */ + private rootInternal_?: Reference; static readonly ServerValue = { TIMESTAMP: { @@ -51,25 +54,70 @@ export class Database implements FirebaseService { /** * The constructor should not be called by users of our public API. - * @param {!Repo} repo_ + * @param {!Repo} repoInternal_ */ - constructor(private repo_: Repo) { - if (!(repo_ instanceof Repo)) { + constructor(private repoInternal_: Repo) { + if (!(repoInternal_ instanceof Repo)) { fatal( "Don't call new Database() directly - please use firebase.database()." ); } + } - /** @type {Reference} */ - this.root_ = new Reference(repo_, Path.Empty); + INTERNAL = { + delete: async () => { + this.checkDeleted_('delete'); + RepoManager.getInstance().deleteRepo(this.repo_); + this.repoInternal_ = null; + this.rootInternal_ = null; + } + }; - this.INTERNAL = new DatabaseInternals(this); + private get repo_(): Repo { + if (!this.instanceStarted_) { + this.repoInternal_.start(); + this.instanceStarted_ = true; + } + return this.repoInternal_; + } + + get root_(): Reference { + if (!this.rootInternal_) { + this.rootInternal_ = new Reference(this.repo_, Path.Empty); + } + + return this.rootInternal_; } get app(): FirebaseApp { return this.repo_.app; } + /** + * Modify this instance to communicate with the Realtime Database emulator. + * + *

Note: This method must be called before performing any other operation. + * + * @param host the emulator host (ex: localhost) + * @param port the emulator port (ex: 8080) + */ + useEmulator(host: string, port: number): void { + this.checkDeleted_('useEmulator'); + if (this.instanceStarted_) { + fatal( + 'Cannot call useEmulator() after instance has already been initialized.' + ); + return; + } + + // Modify the repo to apply emulator settings + RepoManager.getInstance().applyEmulatorSettings( + this.repoInternal_, + host, + port + ); + } + /** * Returns a reference to the root or to the path specified in the provided * argument. @@ -109,14 +157,14 @@ export class Database implements FirebaseService { validateUrl(apiName, 1, parsedURL); const repoInfo = parsedURL.repoInfo; - if (repoInfo.host !== this.repo_.repoInfo_.host) { + if (!repoInfo.isCustomHost() && repoInfo.host !== this.repo_.repoInfo_.host) { fatal( apiName + ': Host name does not match the current database: ' + '(found ' + repoInfo.host + ' but expected ' + - (this.repo_.repoInfo_ as RepoInfo).host + + this.repo_.repoInfo_.host+ ')' ); } @@ -128,7 +176,7 @@ export class Database implements FirebaseService { * @param {string} apiName */ private checkDeleted_(apiName: string) { - if (this.repo_ === null) { + if (this.repoInternal_ === null) { fatal('Cannot call ' + apiName + ' on a deleted database.'); } } @@ -146,22 +194,3 @@ export class Database implements FirebaseService { this.repo_.resume(); } } - -export class DatabaseInternals { - /** @param {!Database} database */ - constructor(public database: Database) {} - - /** @return {Promise} */ - async delete(): Promise { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (this.database as any).checkDeleted_('delete'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - RepoManager.getInstance().deleteRepo((this.database as any).repo_ as Repo); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (this.database as any).repo_ = null; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (this.database as any).root_ = null; - this.database.INTERNAL = null; - this.database = null; - } -} diff --git a/packages/database/src/core/Repo.ts b/packages/database/src/core/Repo.ts index 95a96954626..0c105e7abd6 100644 --- a/packages/database/src/core/Repo.ts +++ b/packages/database/src/core/Repo.ts @@ -54,6 +54,9 @@ const INTERRUPT_REASON = 'repo_interrupt'; * A connection to a single data repository. */ export class Repo { + /** Key for uniquely identifying this repo, used in RepoManager */ + readonly key: string; + dataUpdateCount = 0; private infoSyncTree_: SyncTree; private serverSyncTree_: SyncTree; @@ -81,23 +84,28 @@ export class Repo { constructor( public repoInfo_: RepoInfo, - forceRestClient: boolean, + private forceRestClient_: boolean, public app: FirebaseApp, - authTokenProvider: AuthTokenProvider + public authTokenProvider_: AuthTokenProvider ) { - this.stats_ = StatsManager.getCollection(repoInfo_); + // This key is intentionally not updated if RepoInfo is later changed or replaced + this.key = this.repoInfo_.toURLString(); + } + + start(): void { + this.stats_ = StatsManager.getCollection(this.repoInfo_); - if (forceRestClient || beingCrawled()) { + if (this.forceRestClient_ || beingCrawled()) { this.server_ = new ReadonlyRestClient( this.repoInfo_, this.onDataUpdate_.bind(this), - authTokenProvider + this.authTokenProvider_ ); // Minor hack: Fire onConnect immediately, since there's no actual connection. setTimeout(this.onConnectStatus_.bind(this, true), 0); } else { - const authOverride = app.options['databaseAuthVariableOverride']; + const authOverride = this.app.options['databaseAuthVariableOverride']; // Validate authOverride if (typeof authOverride !== 'undefined' && authOverride !== null) { if (typeof authOverride !== 'object') { @@ -114,25 +122,25 @@ export class Repo { this.persistentConnection_ = new PersistentConnection( this.repoInfo_, - app.options.appId, + this.app.options.appId, this.onDataUpdate_.bind(this), this.onConnectStatus_.bind(this), this.onServerInfoUpdate_.bind(this), - authTokenProvider, + this.authTokenProvider_, authOverride ); this.server_ = this.persistentConnection_; } - authTokenProvider.addTokenChangeListener(token => { + this.authTokenProvider_.addTokenChangeListener(token => { this.server_.refreshAuthToken(token); }); // In the case of multiple Repos for the same repoInfo (i.e. there are multiple Firebase.Contexts being used), // we only want to create one StatsReporter. As such, we'll report stats over the first Repo created. this.statsReporter_ = StatsManager.getOrCreateReporter( - repoInfo_, + this.repoInfo_, () => new StatsReporter(this.stats_, this.server_) ); diff --git a/packages/database/src/core/RepoManager.ts b/packages/database/src/core/RepoManager.ts index 7e06519864d..d670940e6ff 100644 --- a/packages/database/src/core/RepoManager.ts +++ b/packages/database/src/core/RepoManager.ts @@ -87,6 +87,25 @@ export class RepoManager { } } + /** + * Update an existing repo in place to point to a new host/port. + */ + applyEmulatorSettings(repo: Repo, host: string, port: number): void { + repo.repoInfo_ = new RepoInfo( + `${host}:${port}`, + /* secure= */ false, + repo.repoInfo_.namespace, + repo.repoInfo_.webSocketOnly, + repo.repoInfo_.nodeAdmin, + repo.repoInfo_.persistenceKey, + repo.repoInfo_.includeNamespaceInQueryParams + ); + + if (repo.repoInfo_.nodeAdmin) { + repo.authTokenProvider_ = new EmulatorAdminTokenProvider(); + } + } + /** * This function should only ever be called to CREATE a new database instance. * @@ -157,13 +176,13 @@ export class RepoManager { deleteRepo(repo: Repo) { const appRepos = safeGet(this.repos_, repo.app.name); // This should never happen... - if (!appRepos || safeGet(appRepos, repo.repoInfo_.toURLString()) !== repo) { + if (!appRepos || safeGet(appRepos, repo.key) !== repo) { fatal( `Database ${repo.app.name}(${repo.repoInfo_}) has already been deleted.` ); } repo.interrupt(); - delete appRepos[repo.repoInfo_.toURLString()]; + delete appRepos[repo.key]; } /** diff --git a/packages/database/test/database.test.ts b/packages/database/test/database.test.ts index 67dc3653462..719d4e3abd3 100644 --- a/packages/database/test/database.test.ts +++ b/packages/database/test/database.test.ts @@ -229,8 +229,8 @@ describe('Database Tests', () => { }); it('ref() validates project', () => { - const db1 = defaultApp.database('http://bar.foo.com'); - const db2 = defaultApp.database('http://foo.bar.com'); + const db1 = defaultApp.database('http://bar.firebaseio.com'); + const db2 = defaultApp.database('http://foo.firebaseio.com'); const ref1 = db1.ref('child'); @@ -260,4 +260,30 @@ describe('Database Tests', () => { const ref = (db as any).refFromURL(); }).to.throw(/Expects at least 1/); }); + + it('can call useEmulator before use', () => { + const db = (firebase as any).database(); + db.useEmulator('localhost', 1234); + expect(db.ref().toString()).to.equal('http://localhost:1234/'); + }); + + it('cannot call useEmulator after use', () => { + const db = (firebase as any).database(); + + db.ref().set({ + hello: 'world' + }); + + expect(() => { + db.useEmulator('localhost', 1234); + }).to.throw(/Cannot call useEmulator/); + }); + + it('refFromURL returns an emulated ref with useEmulator', () => { + const db = (firebase as any).database(); + db.useEmulator('localhost', 1234); + + const ref = db.refFromURL(DATABASE_ADDRESS + '/path/to/data'); + expect(ref.toString()).to.equal(`http://localhost:1234/path/to/data`); + }); }); diff --git a/packages/firebase/index.d.ts b/packages/firebase/index.d.ts index 179a2e66d0e..695121e7224 100644 --- a/packages/firebase/index.d.ts +++ b/packages/firebase/index.d.ts @@ -5667,6 +5667,15 @@ declare namespace firebase.database { * ``` */ app: firebase.app.App; + /** + * Modify this instance to communicate with the Realtime Database emulator. + * + *

Note: This method must be called before performing any other operation. + * + * @param host the emulator host (ex: localhost) + * @param port the emulator port (ex: 8080) + */ + useEmulator(host: string, port: number): void; /** * Disconnects from the server (all Database operations will be completed * offline).