diff --git a/.changeset/empty-falcons-sit.md b/.changeset/empty-falcons-sit.md new file mode 100644 index 00000000000..776b835a93d --- /dev/null +++ b/.changeset/empty-falcons-sit.md @@ -0,0 +1,9 @@ +--- +'@firebase/app': patch +'@firebase/installations': patch +'@firebase/installations-compat': patch +'@firebase/messaging': patch +'@firebase/util': minor +--- + +Remove idb dependency and replace with our own code. diff --git a/.changeset/neat-olives-punch.md b/.changeset/neat-olives-punch.md new file mode 100644 index 00000000000..9ae7caec82a --- /dev/null +++ b/.changeset/neat-olives-punch.md @@ -0,0 +1,6 @@ +--- +"@firebase/auth-compat": patch +"@firebase/auth": patch +--- + +Heartbeat diff --git a/.changeset/polite-donkeys-divide.md b/.changeset/polite-donkeys-divide.md new file mode 100644 index 00000000000..ef6527ce025 --- /dev/null +++ b/.changeset/polite-donkeys-divide.md @@ -0,0 +1,5 @@ +--- +'@firebase/app-check': patch +--- + +Update platform logging to use new endpoint. diff --git a/.changeset/proud-otters-tap.md b/.changeset/proud-otters-tap.md new file mode 100644 index 00000000000..fe22ae8b8f8 --- /dev/null +++ b/.changeset/proud-otters-tap.md @@ -0,0 +1,5 @@ +--- +'@firebase/installations': patch +--- + +Update platform logging code to send to new endpoint. diff --git a/.changeset/sweet-pumas-dance.md b/.changeset/sweet-pumas-dance.md new file mode 100644 index 00000000000..da2ba9f6b9f --- /dev/null +++ b/.changeset/sweet-pumas-dance.md @@ -0,0 +1,5 @@ +--- +'@firebase/app': patch +--- + +Fix heartbeat controller to ensure not sending more than one a day. diff --git a/packages/app-check/src/client.test.ts b/packages/app-check/src/client.test.ts index afa8399b916..2cf6ef2795a 100644 --- a/packages/app-check/src/client.test.ts +++ b/packages/app-check/src/client.test.ts @@ -19,7 +19,7 @@ import '../test/setup'; import { expect } from 'chai'; import { stub, SinonStub, useFakeTimers } from 'sinon'; import { FirebaseApp } from '@firebase/app'; -import { getFakeApp, getFakePlatformLoggingProvider } from '../test/util'; +import { getFakeApp, getFakeHeartbeatServiceProvider } from '../test/util'; import { getExchangeRecaptchaV3TokenRequest, exchangeToken, @@ -86,7 +86,7 @@ describe('client', () => { const response = await exchangeToken( getExchangeRecaptchaV3TokenRequest(app, 'fake-custom-token'), - getFakePlatformLoggingProvider('a/1.2.3 fire-app-check/2.3.4') + getFakeHeartbeatServiceProvider('a/1.2.3 fire-app-check/2.3.4') ); expect( @@ -114,7 +114,7 @@ describe('client', () => { try { await exchangeToken( getExchangeRecaptchaV3TokenRequest(app, 'fake-custom-token'), - getFakePlatformLoggingProvider() + getFakeHeartbeatServiceProvider() ); } catch (e) { expect(e).instanceOf(FirebaseError); @@ -143,7 +143,7 @@ describe('client', () => { try { await exchangeToken( getExchangeRecaptchaV3TokenRequest(app, 'fake-custom-token'), - getFakePlatformLoggingProvider() + getFakeHeartbeatServiceProvider() ); } catch (e) { expect(e).instanceOf(FirebaseError); @@ -171,7 +171,7 @@ describe('client', () => { try { await exchangeToken( getExchangeRecaptchaV3TokenRequest(app, 'fake-custom-token'), - getFakePlatformLoggingProvider() + getFakeHeartbeatServiceProvider() ); } catch (e) { expect(e).instanceOf(FirebaseError); @@ -205,7 +205,7 @@ describe('client', () => { try { await exchangeToken( getExchangeRecaptchaV3TokenRequest(app, 'fake-custom-token'), - getFakePlatformLoggingProvider() + getFakeHeartbeatServiceProvider() ); } catch (e) { expect(e).instanceOf(FirebaseError); diff --git a/packages/app-check/src/client.ts b/packages/app-check/src/client.ts index 94564191f64..2e4b28e6df4 100644 --- a/packages/app-check/src/client.ts +++ b/packages/app-check/src/client.ts @@ -42,17 +42,20 @@ interface AppCheckRequest { export async function exchangeToken( { url, body }: AppCheckRequest, - platformLoggerProvider: Provider<'platform-logger'> + heartbeatServiceProvider: Provider<'heartbeat'> ): Promise { const headers: HeadersInit = { 'Content-Type': 'application/json' }; - // If platform logger exists, add the platform info string to the header. - const platformLogger = platformLoggerProvider.getImmediate({ + // If heartbeat service exists, add heartbeat header string to the header. + const heartbeatService = heartbeatServiceProvider.getImmediate({ optional: true }); - if (platformLogger) { - headers['X-Firebase-Client'] = platformLogger.getPlatformInfoString(); + if (heartbeatService) { + const heartbeatsHeader = await heartbeatService.getHeartbeatsHeader(); + if (heartbeatsHeader) { + headers['X-Firebase-Client'] = heartbeatsHeader; + } } const options: RequestInit = { method: 'POST', diff --git a/packages/app-check/src/factory.ts b/packages/app-check/src/factory.ts index 8ec0cf528f1..1870772cdd1 100644 --- a/packages/app-check/src/factory.ts +++ b/packages/app-check/src/factory.ts @@ -32,7 +32,7 @@ import { getState } from './state'; export class AppCheckService implements AppCheck, _FirebaseService { constructor( public app: FirebaseApp, - public platformLoggerProvider: Provider<'platform-logger'> + public heartbeatServiceProvider: Provider<'heartbeat'> ) {} _delete(): Promise { const { tokenObservers } = getState(this.app); @@ -45,9 +45,9 @@ export class AppCheckService implements AppCheck, _FirebaseService { export function factory( app: FirebaseApp, - platformLoggerProvider: Provider<'platform-logger'> + heartbeatServiceProvider: Provider<'heartbeat'> ): AppCheckService { - return new AppCheckService(app, platformLoggerProvider); + return new AppCheckService(app, heartbeatServiceProvider); } export function internalFactory( diff --git a/packages/app-check/src/index.ts b/packages/app-check/src/index.ts index 96e7e82eb01..b01b62eec88 100644 --- a/packages/app-check/src/index.ts +++ b/packages/app-check/src/index.ts @@ -48,8 +48,8 @@ function registerAppCheck(): void { container => { // getImmediate for FirebaseApp will always succeed const app = container.getProvider('app').getImmediate(); - const platformLoggerProvider = container.getProvider('platform-logger'); - return factory(app, platformLoggerProvider); + const heartbeatServiceProvider = container.getProvider('heartbeat'); + return factory(app, heartbeatServiceProvider); }, ComponentType.PUBLIC ) diff --git a/packages/app-check/src/internal-api.ts b/packages/app-check/src/internal-api.ts index b7885b1b524..6b086c675cd 100644 --- a/packages/app-check/src/internal-api.ts +++ b/packages/app-check/src/internal-api.ts @@ -106,7 +106,7 @@ export async function getToken( if (!state.exchangeTokenPromise) { state.exchangeTokenPromise = exchangeToken( getExchangeDebugTokenRequest(app, await getDebugToken()), - appCheck.platformLoggerProvider + appCheck.heartbeatServiceProvider ).then(token => { state.exchangeTokenPromise = undefined; return token; diff --git a/packages/app-check/src/providers.ts b/packages/app-check/src/providers.ts index b47b00a02e3..cb07375dcf2 100644 --- a/packages/app-check/src/providers.ts +++ b/packages/app-check/src/providers.ts @@ -46,7 +46,7 @@ import { getDurationString } from './util'; */ export class ReCaptchaV3Provider implements AppCheckProvider { private _app?: FirebaseApp; - private _platformLoggerProvider?: Provider<'platform-logger'>; + private _heartbeatServiceProvider?: Provider<'heartbeat'>; /** * Throttle requests on certain error codes to prevent too many retries * in a short time. @@ -66,7 +66,7 @@ export class ReCaptchaV3Provider implements AppCheckProvider { throwIfThrottled(this._throttleData); // Top-level `getToken()` has already checked that App Check is initialized - // and therefore this._app and this._platformLoggerProvider are available. + // and therefore this._app and this._heartbeatServiceProvider are available. const attestedClaimsToken = await getReCAPTCHAToken(this._app!).catch( _e => { // reCaptcha.execute() throws null which is not very descriptive. @@ -77,7 +77,7 @@ export class ReCaptchaV3Provider implements AppCheckProvider { try { result = await exchangeToken( getExchangeRecaptchaV3TokenRequest(this._app!, attestedClaimsToken), - this._platformLoggerProvider! + this._heartbeatServiceProvider! ); } catch (e) { if ((e as FirebaseError).code === AppCheckError.FETCH_STATUS_ERROR) { @@ -105,7 +105,7 @@ export class ReCaptchaV3Provider implements AppCheckProvider { */ initialize(app: FirebaseApp): void { this._app = app; - this._platformLoggerProvider = _getProvider(app, 'platform-logger'); + this._heartbeatServiceProvider = _getProvider(app, 'heartbeat'); initializeRecaptchaV3(app, this._siteKey).catch(() => { /* we don't care about the initialization result */ }); @@ -131,7 +131,7 @@ export class ReCaptchaV3Provider implements AppCheckProvider { */ export class ReCaptchaEnterpriseProvider implements AppCheckProvider { private _app?: FirebaseApp; - private _platformLoggerProvider?: Provider<'platform-logger'>; + private _heartbeatServiceProvider?: Provider<'heartbeat'>; /** * Throttle requests on certain error codes to prevent too many retries * in a short time. @@ -150,7 +150,7 @@ export class ReCaptchaEnterpriseProvider implements AppCheckProvider { async getToken(): Promise { throwIfThrottled(this._throttleData); // Top-level `getToken()` has already checked that App Check is initialized - // and therefore this._app and this._platformLoggerProvider are available. + // and therefore this._app and this._heartbeatServiceProvider are available. const attestedClaimsToken = await getReCAPTCHAToken(this._app!).catch( _e => { // reCaptcha.execute() throws null which is not very descriptive. @@ -164,7 +164,7 @@ export class ReCaptchaEnterpriseProvider implements AppCheckProvider { this._app!, attestedClaimsToken ), - this._platformLoggerProvider! + this._heartbeatServiceProvider! ); } catch (e) { if ((e as FirebaseError).code === AppCheckError.FETCH_STATUS_ERROR) { @@ -192,7 +192,7 @@ export class ReCaptchaEnterpriseProvider implements AppCheckProvider { */ initialize(app: FirebaseApp): void { this._app = app; - this._platformLoggerProvider = _getProvider(app, 'platform-logger'); + this._heartbeatServiceProvider = _getProvider(app, 'heartbeat'); initializeRecaptchaEnterprise(app, this._siteKey).catch(() => { /* we don't care about the initialization result */ }); diff --git a/packages/app-check/test/util.ts b/packages/app-check/test/util.ts index fa9f1afb41a..4a4f8671adb 100644 --- a/packages/app-check/test/util.ts +++ b/packages/app-check/test/util.ts @@ -30,6 +30,7 @@ import { } from '@firebase/component'; import { AppCheckService } from '../src/factory'; import { AppCheck, CustomProvider } from '../src'; +import { HeartbeatService } from '@firebase/app/dist/app/src/types'; export const FAKE_SITE_KEY = 'fake-site-key'; @@ -55,7 +56,7 @@ export function getFakeApp(overrides: Record = {}): FirebaseApp { export function getFakeAppCheck(app: FirebaseApp): AppCheck { return { app, - platformLoggerProvider: getFakePlatformLoggingProvider() + heartbeatServiceProvider: getFakeHeartbeatServiceProvider() } as AppCheck; } @@ -63,7 +64,7 @@ export function getFullApp(): FirebaseApp { const app = initializeApp(fakeConfig); _registerComponent( new Component( - 'platform-logger', + 'heartbeat', () => { return {} as any; }, @@ -92,19 +93,22 @@ export function getFakeCustomTokenProvider(): CustomProvider { }); } -export function getFakePlatformLoggingProvider( +export function getFakeHeartbeatServiceProvider( fakeLogString: string = 'a/1.2.3 b/2.3.4' -): Provider<'platform-logger'> { +): Provider<'heartbeat'> { const container = new ComponentContainer('test'); container.addComponent( new Component( - 'platform-logger', - () => ({ getPlatformInfoString: () => fakeLogString }), + 'heartbeat', + () => + ({ + getHeartbeatsHeader: () => Promise.resolve(fakeLogString) + } as HeartbeatService), ComponentType.PRIVATE ) ); - return container.getProvider('platform-logger'); + return container.getProvider('heartbeat'); } export function getFakeGreCAPTCHA( diff --git a/packages/app/package.json b/packages/app/package.json index aff6525f0c1..846bbc18110 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -40,7 +40,6 @@ "@firebase/util": "1.4.3", "@firebase/logger": "0.3.2", "@firebase/component": "0.5.10", - "idb": "3.0.2", "tslib": "^2.1.0" }, "license": "Apache-2.0", diff --git a/packages/app/src/heartbeatService.test.ts b/packages/app/src/heartbeatService.test.ts index ad0d453b706..8877cb342fc 100644 --- a/packages/app/src/heartbeatService.test.ts +++ b/packages/app/src/heartbeatService.test.ts @@ -32,7 +32,6 @@ import { FirebaseApp } from './public-types'; import * as firebaseUtil from '@firebase/util'; import { SinonStub, stub, useFakeTimers } from 'sinon'; import * as indexedDb from './indexeddb'; -import { base64Encode, isIndexedDBAvailable } from '@firebase/util'; declare module '@firebase/component' { interface NameServiceMapping { @@ -99,38 +98,37 @@ describe('HeartbeatServiceImpl', () => { */ it(`triggerHeartbeat() stores a heartbeat`, async () => { await heartbeatService.triggerHeartbeat(); - expect(heartbeatService._heartbeatsCache?.length).to.equal(1); - const heartbeat1 = heartbeatService._heartbeatsCache?.[0]; - expect(heartbeat1?.userAgent).to.equal(USER_AGENT_STRING_1); + expect(heartbeatService._heartbeatsCache?.heartbeats.length).to.equal(1); + const heartbeat1 = heartbeatService._heartbeatsCache?.heartbeats[0]; + expect(heartbeat1?.agent).to.equal(USER_AGENT_STRING_1); expect(heartbeat1?.date).to.equal('1970-01-01'); - expect(writeStub).to.be.calledWith([heartbeat1]); + expect(writeStub).to.be.calledWith({ heartbeats: [heartbeat1] }); }); it(`triggerHeartbeat() doesn't store another heartbeat on the same day`, async () => { - expect(heartbeatService._heartbeatsCache?.length).to.equal(1); + expect(heartbeatService._heartbeatsCache?.heartbeats.length).to.equal(1); await heartbeatService.triggerHeartbeat(); - expect(heartbeatService._heartbeatsCache?.length).to.equal(1); + expect(heartbeatService._heartbeatsCache?.heartbeats.length).to.equal(1); }); it(`triggerHeartbeat() does store another heartbeat on a different day`, async () => { - expect(heartbeatService._heartbeatsCache?.length).to.equal(1); + expect(heartbeatService._heartbeatsCache?.heartbeats.length).to.equal(1); clock.tick(24 * 60 * 60 * 1000); await heartbeatService.triggerHeartbeat(); - expect(heartbeatService._heartbeatsCache?.length).to.equal(2); - expect(heartbeatService._heartbeatsCache?.[1].date).to.equal( + expect(heartbeatService._heartbeatsCache?.heartbeats.length).to.equal(2); + expect(heartbeatService._heartbeatsCache?.heartbeats[1].date).to.equal( '1970-01-02' ); }); it(`triggerHeartbeat() stores another entry for a different user agent`, async () => { userAgentString = USER_AGENT_STRING_2; - expect(heartbeatService._heartbeatsCache?.length).to.equal(2); + expect(heartbeatService._heartbeatsCache?.heartbeats.length).to.equal(2); clock.tick(2 * 24 * 60 * 60 * 1000); await heartbeatService.triggerHeartbeat(); - expect(heartbeatService._heartbeatsCache?.length).to.equal(3); - expect(heartbeatService._heartbeatsCache?.[2].date).to.equal( + expect(heartbeatService._heartbeatsCache?.heartbeats.length).to.equal(3); + expect(heartbeatService._heartbeatsCache?.heartbeats[2].date).to.equal( '1970-01-03' ); }); it('getHeartbeatHeaders() gets stored heartbeats and clears heartbeats', async () => { - const deleteStub = stub(heartbeatService._storage, 'deleteAll'); const heartbeatHeaders = firebaseUtil.base64Decode( await heartbeatService.getHeartbeatsHeader() ); @@ -140,10 +138,13 @@ describe('HeartbeatServiceImpl', () => { expect(heartbeatHeaders).to.include('1970-01-02'); expect(heartbeatHeaders).to.include('1970-01-03'); expect(heartbeatHeaders).to.include(`"version":2`); - expect(heartbeatService._heartbeatsCache).to.equal(null); + expect(heartbeatService._heartbeatsCache?.heartbeats).to.be.empty; + expect(writeStub).to.be.calledWith({ + lastSentHeartbeatDate: '1970-01-01', + heartbeats: [] + }); const emptyHeaders = await heartbeatService.getHeartbeatsHeader(); expect(emptyHeaders).to.equal(''); - expect(deleteStub).to.be.called; }); }); describe('If IndexedDB has entries', () => { @@ -154,11 +155,11 @@ describe('HeartbeatServiceImpl', () => { const mockIndexedDBHeartbeats = [ // Chosen so one will exceed 30 day limit and one will not. { - userAgent: 'old-user-agent', + agent: 'old-user-agent', date: '1969-12-01' }, { - userAgent: 'old-user-agent', + agent: 'old-user-agent', date: '1969-12-31' } ]; @@ -197,61 +198,149 @@ describe('HeartbeatServiceImpl', () => { */ it(`new heartbeat service reads from indexedDB cache`, async () => { const promiseResult = await heartbeatService._heartbeatsCachePromise; - if (isIndexedDBAvailable()) { - expect(promiseResult).to.deep.equal(mockIndexedDBHeartbeats); - expect(heartbeatService._heartbeatsCache).to.deep.equal( - mockIndexedDBHeartbeats - ); + if (firebaseUtil.isIndexedDBAvailable()) { + expect(promiseResult).to.deep.equal({ + heartbeats: mockIndexedDBHeartbeats + }); + expect(heartbeatService._heartbeatsCache).to.deep.equal({ + heartbeats: mockIndexedDBHeartbeats + }); } else { // In Node or other no-indexed-db environments it will fail the // `canUseIndexedDb` check and return an empty array. - expect(promiseResult).to.deep.equal([]); - expect(heartbeatService._heartbeatsCache).to.deep.equal([]); + expect(promiseResult).to.deep.equal({ + heartbeats: [] + }); + expect(heartbeatService._heartbeatsCache).to.deep.equal({ + heartbeats: [] + }); } }); it(`triggerHeartbeat() writes new heartbeats and retains old ones newer than 30 days`, async () => { userAgentString = USER_AGENT_STRING_2; clock.tick(3 * 24 * 60 * 60 * 1000); await heartbeatService.triggerHeartbeat(); - if (isIndexedDBAvailable()) { - expect(writeStub).to.be.calledWith([ - // The first entry exceeds the 30 day retention limit. - mockIndexedDBHeartbeats[1], - { userAgent: USER_AGENT_STRING_2, date: '1970-01-04' } - ]); + if (firebaseUtil.isIndexedDBAvailable()) { + expect(writeStub).to.be.calledWith({ + heartbeats: [ + // The first entry exceeds the 30 day retention limit. + mockIndexedDBHeartbeats[1], + { agent: USER_AGENT_STRING_2, date: '1970-01-04' } + ] + }); } else { - expect(writeStub).to.be.calledWith([ - { userAgent: USER_AGENT_STRING_2, date: '1970-01-04' } - ]); + expect(writeStub).to.be.calledWith({ + heartbeats: [{ agent: USER_AGENT_STRING_2, date: '1970-01-04' }] + }); } }); it('getHeartbeatHeaders() gets stored heartbeats and clears heartbeats', async () => { - const deleteStub = stub(heartbeatService._storage, 'deleteAll'); const heartbeatHeaders = firebaseUtil.base64Decode( await heartbeatService.getHeartbeatsHeader() ); - if (isIndexedDBAvailable()) { + if (firebaseUtil.isIndexedDBAvailable()) { expect(heartbeatHeaders).to.include('old-user-agent'); expect(heartbeatHeaders).to.include('1969-12-31'); } expect(heartbeatHeaders).to.include(USER_AGENT_STRING_2); expect(heartbeatHeaders).to.include('1970-01-04'); expect(heartbeatHeaders).to.include(`"version":2`); - expect(heartbeatService._heartbeatsCache).to.equal(null); + expect(heartbeatService._heartbeatsCache?.heartbeats).to.be.empty; + expect(writeStub).to.be.calledWith({ + lastSentHeartbeatDate: '1970-01-01', + heartbeats: [] + }); const emptyHeaders = await heartbeatService.getHeartbeatsHeader(); expect(emptyHeaders).to.equal(''); - expect(deleteStub).to.be.called; + }); + }); + + describe('If IndexedDB records that a header was sent today', () => { + let heartbeatService: HeartbeatServiceImpl; + let writeStub: SinonStub; + const userAgentString = USER_AGENT_STRING_1; + const mockIndexedDBHeartbeats = [ + // Chosen so one will exceed 30 day limit and one will not. + { + agent: 'old-user-agent', + date: '1969-12-01' + }, + { + agent: 'old-user-agent', + date: '1969-12-31' + } + ]; + before(() => { + const container = new ComponentContainer('heartbeatTestContainer'); + container.addComponent( + new Component( + 'app', + () => + ({ + options: { appId: 'an-app-id' }, + name: 'an-app-name' + } as FirebaseApp), + ComponentType.VERSION + ) + ); + container.addComponent( + new Component( + 'platform-logger', + () => ({ getPlatformInfoString: () => userAgentString }), + ComponentType.VERSION + ) + ); + stub(indexedDb, 'readHeartbeatsFromIndexedDB').resolves({ + lastSentHeartbeatDate: '1970-01-01', + heartbeats: [...mockIndexedDBHeartbeats] + }); + heartbeatService = new HeartbeatServiceImpl(container); + }); + beforeEach(() => { + useFakeTimers(); + writeStub = stub(heartbeatService._storage, 'overwrite'); + }); + it(`new heartbeat service reads from indexedDB cache`, async () => { + const promiseResult = await heartbeatService._heartbeatsCachePromise; + if (firebaseUtil.isIndexedDBAvailable()) { + expect(promiseResult).to.deep.equal({ + lastSentHeartbeatDate: '1970-01-01', + heartbeats: mockIndexedDBHeartbeats + }); + expect(heartbeatService._heartbeatsCache).to.deep.equal({ + lastSentHeartbeatDate: '1970-01-01', + heartbeats: mockIndexedDBHeartbeats + }); + } else { + // In Node or other no-indexed-db environments it will fail the + // `canUseIndexedDb` check and return an empty array. + expect(promiseResult).to.deep.equal({ + heartbeats: [] + }); + expect(heartbeatService._heartbeatsCache).to.deep.equal({ + heartbeats: [] + }); + } + }); + it(`triggerHeartbeat() will skip storing new data`, async () => { + await heartbeatService.triggerHeartbeat(); + expect(writeStub).to.not.be.called; + if (firebaseUtil.isIndexedDBAvailable()) { + expect(heartbeatService._heartbeatsCache?.heartbeats).to.deep.equal( + mockIndexedDBHeartbeats + ); + } }); }); describe('countBytes()', () => { it('counts how many bytes there will be in a stringified, encoded header', () => { const heartbeats = [ - { userAgent: generateUserAgentString(1), dates: generateDates(1) }, - { userAgent: generateUserAgentString(3), dates: generateDates(2) } + { agent: generateUserAgentString(1), dates: generateDates(1) }, + { agent: generateUserAgentString(3), dates: generateDates(2) } ]; let size: number = 0; - const headerString = base64Encode( + const headerString = firebaseUtil.base64urlEncodeWithoutPadding( JSON.stringify({ version: 2, heartbeats }) ); // Use independent methods to validate our byte count method matches. @@ -272,7 +361,7 @@ describe('HeartbeatServiceImpl', () => { describe('_extractHeartbeatsForHeader()', () => { it('returns empty heartbeatsToKeep if it cannot get under maxSize', () => { const heartbeats = [ - { userAgent: generateUserAgentString(1), date: '2022-01-01' } + { agent: generateUserAgentString(1), date: '2022-01-01' } ]; const { unsentEntries, heartbeatsToSend } = extractHeartbeatsForHeader( heartbeats, @@ -283,11 +372,11 @@ describe('HeartbeatServiceImpl', () => { }); it('splits heartbeats array', () => { const heartbeats = [ - { userAgent: generateUserAgentString(20), date: '2022-01-01' }, - { userAgent: generateUserAgentString(4), date: '2022-01-02' } + { agent: generateUserAgentString(20), date: '2022-01-01' }, + { agent: generateUserAgentString(4), date: '2022-01-02' } ]; const sizeWithHeartbeat0Only = countBytes([ - { userAgent: heartbeats[0].userAgent, dates: [heartbeats[0].date] } + { agent: heartbeats[0].agent, dates: [heartbeats[0].date] } ]); const { unsentEntries, heartbeatsToSend } = extractHeartbeatsForHeader( heartbeats, @@ -299,12 +388,12 @@ describe('HeartbeatServiceImpl', () => { it('splits the first heartbeat if needed', () => { const uaString = generateUserAgentString(20); const heartbeats = [ - { userAgent: uaString, date: '2022-01-01' }, - { userAgent: uaString, date: '2022-01-02' }, - { userAgent: uaString, date: '2022-01-03' } + { agent: uaString, date: '2022-01-01' }, + { agent: uaString, date: '2022-01-02' }, + { agent: uaString, date: '2022-01-03' } ]; const sizeWithHeartbeat0Only = countBytes([ - { userAgent: heartbeats[0].userAgent, dates: [heartbeats[0].date] } + { agent: heartbeats[0].agent, dates: [heartbeats[0].date] } ]); const { unsentEntries, heartbeatsToSend } = extractHeartbeatsForHeader( heartbeats, diff --git a/packages/app/src/heartbeatService.ts b/packages/app/src/heartbeatService.ts index 88ada8c9cf9..0c15460ba61 100644 --- a/packages/app/src/heartbeatService.ts +++ b/packages/app/src/heartbeatService.ts @@ -17,12 +17,11 @@ import { ComponentContainer } from '@firebase/component'; import { - base64Encode, + base64urlEncodeWithoutPadding, isIndexedDBAvailable, validateIndexedDBOpenable } from '@firebase/util'; import { - deleteHeartbeatsFromIndexedDB, readHeartbeatsFromIndexedDB, writeHeartbeatsToIndexedDB } from './indexeddb'; @@ -30,6 +29,7 @@ import { FirebaseApp } from './public-types'; import { HeartbeatsByUserAgent, HeartbeatService, + HeartbeatsInIndexedDB, HeartbeatStorage, SingleDateHeartbeat } from './types'; @@ -54,7 +54,7 @@ export class HeartbeatServiceImpl implements HeartbeatService { * be kept in sync with indexedDB. * Leave public for easier testing. */ - _heartbeatsCache: SingleDateHeartbeat[] | null = null; + _heartbeatsCache: HeartbeatsInIndexedDB | null = null; /** * the initialization promise for populating heartbeatCache. @@ -62,7 +62,7 @@ export class HeartbeatServiceImpl implements HeartbeatService { * (hearbeatsCache == null), it should wait for this promise * Leave public for easier testing. */ - _heartbeatsCachePromise: Promise; + _heartbeatsCachePromise: Promise; constructor(private readonly container: ComponentContainer) { const app = this.container.getProvider('app').getImmediate(); this._storage = new HeartbeatStorageImpl(app); @@ -86,24 +86,26 @@ export class HeartbeatServiceImpl implements HeartbeatService { // This is the "Firebase user agent" string from the platform logger // service, not the browser user agent. - const userAgent = platformLogger.getPlatformInfoString(); + const agent = platformLogger.getPlatformInfoString(); const date = getUTCDateString(); if (this._heartbeatsCache === null) { this._heartbeatsCache = await this._heartbeatsCachePromise; } + // Do not store a heartbeat if one is already stored for this day + // or if a header has already been sent today. if ( - this._heartbeatsCache.some( + this._heartbeatsCache.lastSentHeartbeatDate === date || + this._heartbeatsCache.heartbeats.some( singleDateHeartbeat => singleDateHeartbeat.date === date ) ) { - // Do not store a heartbeat if one is already stored for this day. return; } else { // There is no entry for this date. Create one. - this._heartbeatsCache.push({ date, userAgent }); + this._heartbeatsCache.heartbeats.push({ date, agent }); } // Remove entries older than 30 days. - this._heartbeatsCache = this._heartbeatsCache.filter( + this._heartbeatsCache.heartbeats = this._heartbeatsCache.heartbeats.filter( singleDateHeartbeat => { const hbTimestamp = new Date(singleDateHeartbeat.date).valueOf(); const now = Date.now(); @@ -117,34 +119,41 @@ export class HeartbeatServiceImpl implements HeartbeatService { * Returns a base64 encoded string which can be attached to the heartbeat-specific header directly. * It also clears all heartbeats from memory as well as in IndexedDB. * - * NOTE: It will read heartbeats from the heartbeatsCache, instead of from indexedDB to reduce latency + * NOTE: Consuming product SDKs should not send the header if this method + * returns an empty string. */ async getHeartbeatsHeader(): Promise { if (this._heartbeatsCache === null) { await this._heartbeatsCachePromise; } - // If it's still null, it's been cleared and has not been repopulated. - if (this._heartbeatsCache === null) { + // If it's still null or the array is empty, there is no data to send. + if ( + this._heartbeatsCache === null || + this._heartbeatsCache.heartbeats.length === 0 + ) { return ''; } + const date = getUTCDateString(); // Extract as many heartbeats from the cache as will fit under the size limit. const { heartbeatsToSend, unsentEntries } = extractHeartbeatsForHeader( - this._heartbeatsCache + this._heartbeatsCache.heartbeats ); - const headerString = base64Encode( + const headerString = base64urlEncodeWithoutPadding( JSON.stringify({ version: 2, heartbeats: heartbeatsToSend }) ); + // Store last sent date to prevent another being logged/sent for the same day. + this._heartbeatsCache.lastSentHeartbeatDate = date; if (unsentEntries.length > 0) { // Store any unsent entries if they exist. - this._heartbeatsCache = unsentEntries; - // This seems more likely than deleteAll (below) to lead to some odd state + this._heartbeatsCache.heartbeats = unsentEntries; + // This seems more likely than emptying the array (below) to lead to some odd state // since the cache isn't empty and this will be called again on the next request, // and is probably safest if we await it. await this._storage.overwrite(this._heartbeatsCache); } else { - this._heartbeatsCache = null; + this._heartbeatsCache.heartbeats = []; // Do not wait for this, to reduce latency. - void this._storage.deleteAll(); + void this._storage.overwrite(this._heartbeatsCache); } return headerString; } @@ -171,12 +180,12 @@ export function extractHeartbeatsForHeader( for (const singleDateHeartbeat of heartbeatsCache) { // Look for an existing entry with the same user agent. const heartbeatEntry = heartbeatsToSend.find( - hb => hb.userAgent === singleDateHeartbeat.userAgent + hb => hb.agent === singleDateHeartbeat.agent ); if (!heartbeatEntry) { // If no entry for this user agent exists, create one. heartbeatsToSend.push({ - userAgent: singleDateHeartbeat.userAgent, + agent: singleDateHeartbeat.agent, dates: [singleDateHeartbeat.date] }); if (countBytes(heartbeatsToSend) > maxSize) { @@ -221,59 +230,48 @@ export class HeartbeatStorageImpl implements HeartbeatStorage { /** * Read all heartbeats. */ - async read(): Promise { + async read(): Promise { const canUseIndexedDB = await this._canUseIndexedDBPromise; if (!canUseIndexedDB) { - return []; + return { heartbeats: [] }; } else { const idbHeartbeatObject = await readHeartbeatsFromIndexedDB(this.app); - return idbHeartbeatObject?.heartbeats || []; + return idbHeartbeatObject || { heartbeats: [] }; } } // overwrite the storage with the provided heartbeats - async overwrite(heartbeats: SingleDateHeartbeat[]): Promise { - const canUseIndexedDB = await this._canUseIndexedDBPromise; - if (!canUseIndexedDB) { - return; - } else { - return writeHeartbeatsToIndexedDB(this.app, { heartbeats }); - } - } - // add heartbeats - async add(heartbeats: SingleDateHeartbeat[]): Promise { + async overwrite(heartbeatsObject: HeartbeatsInIndexedDB): Promise { const canUseIndexedDB = await this._canUseIndexedDBPromise; if (!canUseIndexedDB) { return; } else { - const existingHeartbeats = await this.read(); + const existingHeartbeatsObject = await this.read(); return writeHeartbeatsToIndexedDB(this.app, { - heartbeats: [...existingHeartbeats, ...heartbeats] + lastSentHeartbeatDate: + heartbeatsObject.lastSentHeartbeatDate ?? + existingHeartbeatsObject.lastSentHeartbeatDate, + heartbeats: heartbeatsObject.heartbeats }); } } - // delete heartbeats - async delete(heartbeats: SingleDateHeartbeat[]): Promise { + // add heartbeats + async add(heartbeatsObject: HeartbeatsInIndexedDB): Promise { const canUseIndexedDB = await this._canUseIndexedDBPromise; if (!canUseIndexedDB) { return; } else { - const existingHeartbeats = await this.read(); + const existingHeartbeatsObject = await this.read(); return writeHeartbeatsToIndexedDB(this.app, { - heartbeats: existingHeartbeats.filter( - existingHeartbeat => !heartbeats.includes(existingHeartbeat) - ) + lastSentHeartbeatDate: + heartbeatsObject.lastSentHeartbeatDate ?? + existingHeartbeatsObject.lastSentHeartbeatDate, + heartbeats: [ + ...existingHeartbeatsObject.heartbeats, + ...heartbeatsObject.heartbeats + ] }); } } - // delete all heartbeats - async deleteAll(): Promise { - const canUseIndexedDB = await this._canUseIndexedDBPromise; - if (!canUseIndexedDB) { - return; - } else { - return deleteHeartbeatsFromIndexedDB(this.app); - } - } } /** @@ -283,7 +281,7 @@ export class HeartbeatStorageImpl implements HeartbeatStorage { */ export function countBytes(heartbeatsCache: HeartbeatsByUserAgent[]): number { // base64 has a restricted set of characters, all of which should be 1 byte. - return base64Encode( + return base64urlEncodeWithoutPadding( // heartbeatsCache wrapper properties JSON.stringify({ version: 2, heartbeats: heartbeatsCache }) ).length; diff --git a/packages/app/src/indexeddb.ts b/packages/app/src/indexeddb.ts index 5bdaad0b1b3..e136d895f1f 100644 --- a/packages/app/src/indexeddb.ts +++ b/packages/app/src/indexeddb.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { DB, openDb } from 'idb'; +import { DBWrapper, openDB } from '@firebase/util'; import { AppError, ERROR_FACTORY } from './errors'; import { FirebaseApp } from './public-types'; import { HeartbeatsInIndexedDB } from './types'; @@ -23,18 +23,18 @@ const DB_NAME = 'firebase-heartbeat-database'; const DB_VERSION = 1; const STORE_NAME = 'firebase-heartbeat-store'; -let dbPromise: Promise | null = null; -function getDbPromise(): Promise { +let dbPromise: Promise | null = null; +function getDbPromise(): Promise { if (!dbPromise) { - dbPromise = openDb(DB_NAME, DB_VERSION, upgradeDB => { + dbPromise = openDB(DB_NAME, DB_VERSION, (db, oldVersion) => { // We don't use 'break' in this switch statement, the fall-through // behavior is what we want, because if there are multiple versions between // the old version and the current version, we want ALL the migrations // that correspond to those versions to run, not only the last one. // eslint-disable-next-line default-case - switch (upgradeDB.oldVersion) { + switch (oldVersion) { case 0: - upgradeDB.createObjectStore(STORE_NAME); + db.createObjectStore(STORE_NAME); } }).catch(e => { throw ERROR_FACTORY.create(AppError.STORAGE_OPEN, { @@ -53,7 +53,7 @@ export async function readHeartbeatsFromIndexedDB( return db .transaction(STORE_NAME) .objectStore(STORE_NAME) - .get(computeKey(app)); + .get(computeKey(app)) as Promise; } catch (e) { throw ERROR_FACTORY.create(AppError.STORAGE_GET, { originalErrorMessage: e.message @@ -78,21 +78,6 @@ export async function writeHeartbeatsToIndexedDB( } } -export async function deleteHeartbeatsFromIndexedDB( - app: FirebaseApp -): Promise { - try { - const db = await getDbPromise(); - const tx = db.transaction(STORE_NAME, 'readwrite'); - await tx.objectStore(STORE_NAME).delete(computeKey(app)); - return tx.complete; - } catch (e) { - throw ERROR_FACTORY.create(AppError.STORAGE_DELETE, { - originalErrorMessage: e.message - }); - } -} - function computeKey(app: FirebaseApp): string { return `${app.name}!${app.options.appId}`; } diff --git a/packages/app/src/types.ts b/packages/app/src/types.ts index 2b47c967382..9b78fb7b02c 100644 --- a/packages/app/src/types.ts +++ b/packages/app/src/types.ts @@ -42,28 +42,25 @@ export interface HeartbeatService { // Heartbeats grouped by the same user agent string export interface HeartbeatsByUserAgent { - userAgent: string; + agent: string; dates: string[]; } export interface SingleDateHeartbeat { - userAgent: string; + agent: string; date: string; } export interface HeartbeatStorage { // overwrite the storage with the provided heartbeats - overwrite(heartbeats: SingleDateHeartbeat[]): Promise; + overwrite(heartbeats: HeartbeatsInIndexedDB): Promise; // add heartbeats - add(heartbeats: SingleDateHeartbeat[]): Promise; - // delete heartbeats - delete(heartbeats: SingleDateHeartbeat[]): Promise; - // delete all heartbeats - deleteAll(): Promise; + add(heartbeats: HeartbeatsInIndexedDB): Promise; // read all heartbeats - read(): Promise; + read(): Promise; } export interface HeartbeatsInIndexedDB { + lastSentHeartbeatDate?: string; heartbeats: SingleDateHeartbeat[]; } diff --git a/packages/auth-compat/src/auth.test.ts b/packages/auth-compat/src/auth.test.ts index 0299170827f..af49682afed 100644 --- a/packages/auth-compat/src/auth.test.ts +++ b/packages/auth-compat/src/auth.test.ts @@ -24,6 +24,7 @@ import sinonChai from 'sinon-chai'; import { Auth } from './auth'; import { CompatPopupRedirectResolver } from './popup_redirect'; import * as platform from './platform'; +import { FAKE_HEARTBEAT_CONTROLLER_PROVIDER } from '../test/helpers/helpers'; use(sinonChai); @@ -41,9 +42,13 @@ describe('auth compat', () => { beforeEach(() => { app = { options: { apiKey: 'api-key' } } as FirebaseApp; - underlyingAuth = new exp.AuthImpl(app, { - apiKey: 'api-key' - } as exp.ConfigInternal); + underlyingAuth = new exp.AuthImpl( + app, + FAKE_HEARTBEAT_CONTROLLER_PROVIDER, + { + apiKey: 'api-key' + } as exp.ConfigInternal + ); sinon.stub(underlyingAuth, '_initializeWithPersistence'); providerStub = sinon.createStubInstance(Provider); diff --git a/packages/auth-compat/src/popup_redirect.test.ts b/packages/auth-compat/src/popup_redirect.test.ts index be01b8f622e..80bc382e4fa 100644 --- a/packages/auth-compat/src/popup_redirect.test.ts +++ b/packages/auth-compat/src/popup_redirect.test.ts @@ -22,6 +22,7 @@ import * as exp from '@firebase/auth/internal'; import * as platform from './platform'; import { CompatPopupRedirectResolver } from './popup_redirect'; import { FirebaseApp } from '@firebase/app-compat'; +import { FAKE_HEARTBEAT_CONTROLLER_PROVIDER } from '../test/helpers/helpers'; use(sinonChai); @@ -41,7 +42,7 @@ describe('popup_redirect/CompatPopupRedirectResolver', () => { beforeEach(() => { compatResolver = new CompatPopupRedirectResolver(); const app = { options: { apiKey: 'api-key' } } as FirebaseApp; - auth = new exp.AuthImpl(app, { + auth = new exp.AuthImpl(app, FAKE_HEARTBEAT_CONTROLLER_PROVIDER, { apiKey: 'api-key' } as exp.ConfigInternal); }); diff --git a/packages/auth-compat/test/helpers/helpers.ts b/packages/auth-compat/test/helpers/helpers.ts index 005f8efa61a..aa5069461c6 100644 --- a/packages/auth-compat/test/helpers/helpers.ts +++ b/packages/auth-compat/test/helpers/helpers.ts @@ -17,6 +17,7 @@ import * as sinon from 'sinon'; import firebase from '@firebase/app-compat'; +import { Provider } from '@firebase/component'; import '../..'; import * as exp from '@firebase/auth/internal'; @@ -26,6 +27,13 @@ import { } from '../../../auth/test/helpers/integration/settings'; import { resetEmulator } from '../../../auth/test/helpers/integration/emulator_rest_helpers'; +// Heartbeat is fully tested in core auth impl +export const FAKE_HEARTBEAT_CONTROLLER_PROVIDER = { + getImmediate(): undefined { + return undefined; + } +} as unknown as Provider<'heartbeat'>; + export function initializeTestInstance(): void { firebase.initializeApp(getAppConfig()); const stub = stubConsoleToSilenceEmulatorWarnings(); diff --git a/packages/auth/src/api/index.ts b/packages/auth/src/api/index.ts index f727d8d2f66..a3375a2829c 100644 --- a/packages/auth/src/api/index.ts +++ b/packages/auth/src/api/index.ts @@ -37,7 +37,8 @@ export const enum HttpHeader { CONTENT_TYPE = 'Content-Type', X_FIREBASE_LOCALE = 'X-Firebase-Locale', X_CLIENT_VERSION = 'X-Client-Version', - X_FIREBASE_GMPID = 'X-Firebase-gmpid' + X_FIREBASE_GMPID = 'X-Firebase-gmpid', + X_FIREBASE_CLIENT = 'X-Firebase-Client', } export const enum Endpoint { diff --git a/packages/auth/src/core/auth/auth_impl.test.ts b/packages/auth/src/core/auth/auth_impl.test.ts index 4e226973a0c..bfc8ca75223 100644 --- a/packages/auth/src/core/auth/auth_impl.test.ts +++ b/packages/auth/src/core/auth/auth_impl.test.ts @@ -23,7 +23,7 @@ import sinonChai from 'sinon-chai'; import { FirebaseApp } from '@firebase/app'; import { FirebaseError } from '@firebase/util'; -import { testAuth, testUser } from '../../../test/helpers/mock_auth'; +import { FAKE_HEARTBEAT_CONTROLLER, FAKE_HEARTBEAT_CONTROLLER_PROVIDER, testAuth, testUser } from '../../../test/helpers/mock_auth'; import { AuthInternal } from '../../model/auth'; import { UserInternal } from '../../model/user'; import { PersistenceInternal } from '../persistence'; @@ -53,7 +53,7 @@ describe('core/auth/auth_impl', () => { beforeEach(async () => { persistenceStub = sinon.stub(_getInstance(inMemoryPersistence)); - const authImpl = new AuthImpl(FAKE_APP, { + const authImpl = new AuthImpl(FAKE_APP, FAKE_HEARTBEAT_CONTROLLER_PROVIDER, { apiKey: FAKE_APP.options.apiKey!, apiHost: DefaultConfig.API_HOST, apiScheme: DefaultConfig.API_SCHEME, @@ -431,7 +431,7 @@ describe('core/auth/auth_impl', () => { }); it('prevents initialization from completing', async () => { - const authImpl = new AuthImpl(FAKE_APP, { + const authImpl = new AuthImpl(FAKE_APP, FAKE_HEARTBEAT_CONTROLLER_PROVIDER, { apiKey: FAKE_APP.options.apiKey!, apiHost: DefaultConfig.API_HOST, apiScheme: DefaultConfig.API_SCHEME, @@ -474,6 +474,29 @@ describe('core/auth/auth_impl', () => { 'X-Client-Version': 'v', 'X-Firebase-gmpid': 'app-id', }); + delete auth.app.options.appId; + }); + + it('adds the heartbeat if available', async () => { + sinon.stub(FAKE_HEARTBEAT_CONTROLLER, 'getHeartbeatsHeader').returns(Promise.resolve('heartbeat')); + expect(await auth._getAdditionalHeaders()).to.eql({ + 'X-Client-Version': 'v', + 'X-Firebase-Client': 'heartbeat', + }); + }); + + it('does not add heartbeat if none returned', async () => { + sinon.stub(FAKE_HEARTBEAT_CONTROLLER, 'getHeartbeatsHeader').returns(Promise.resolve('')); + expect(await auth._getAdditionalHeaders()).to.eql({ + 'X-Client-Version': 'v', + }); + }); + + it('does not add heartbeat if controller unavailable', async () => { + sinon.stub(FAKE_HEARTBEAT_CONTROLLER_PROVIDER, 'getImmediate').returns(undefined as any); + expect(await auth._getAdditionalHeaders()).to.eql({ + 'X-Client-Version': 'v', + }); }); }); }); diff --git a/packages/auth/src/core/auth/auth_impl.ts b/packages/auth/src/core/auth/auth_impl.ts index b71e4e24bb4..d20c1596874 100644 --- a/packages/auth/src/core/auth/auth_impl.ts +++ b/packages/auth/src/core/auth/auth_impl.ts @@ -16,6 +16,7 @@ */ import { _FirebaseService, FirebaseApp } from '@firebase/app'; +import { Provider } from '@firebase/component'; import { Auth, AuthErrorMap, @@ -103,6 +104,7 @@ export class AuthImpl implements AuthInternal, _FirebaseService { constructor( public readonly app: FirebaseApp, + private readonly heartbeatServiceProvider: Provider<'heartbeat'>, public readonly config: ConfigInternal ) { this.name = app.name; @@ -583,9 +585,18 @@ export class AuthImpl implements AuthInternal, _FirebaseService { const headers: Record = { [HttpHeader.X_CLIENT_VERSION]: this.clientVersion, }; + if (this.app.options.appId) { headers[HttpHeader.X_FIREBASE_GMPID] = this.app.options.appId; } + + // If the heartbeat service exists, add the heartbeat string + const heartbeatsHeader = await this.heartbeatServiceProvider.getImmediate({ + optional: true, + })?.getHeartbeatsHeader(); + if (heartbeatsHeader) { + headers[HttpHeader.X_FIREBASE_CLIENT] = heartbeatsHeader; + } return headers; } } diff --git a/packages/auth/src/core/auth/register.ts b/packages/auth/src/core/auth/register.ts index abff9c25db0..a8439bc418a 100644 --- a/packages/auth/src/core/auth/register.ts +++ b/packages/auth/src/core/auth/register.ts @@ -19,7 +19,7 @@ import { _registerComponent, registerVersion } from '@firebase/app'; import { Component, ComponentType, - InstantiationMode + InstantiationMode, } from '@firebase/component'; import { name, version } from '../../../package.json'; @@ -61,8 +61,9 @@ export function registerAuth(clientPlatform: ClientPlatform): void { _ComponentName.AUTH, (container, { options: deps }: { options?: Dependencies }) => { const app = container.getProvider('app').getImmediate()!; + const heartbeatServiceProvider = container.getProvider<'heartbeat'>('heartbeat'); const { apiKey, authDomain } = app.options; - return (app => { + return ((app, heartbeatServiceProvider) => { _assert( apiKey && !apiKey.includes(':'), AuthErrorCode.INVALID_API_KEY, @@ -82,11 +83,11 @@ export function registerAuth(clientPlatform: ClientPlatform): void { sdkClientVersion: _getClientVersion(clientPlatform) }; - const authInstance = new AuthImpl(app, config); + const authInstance = new AuthImpl(app, heartbeatServiceProvider, config); _initializeAuthInstance(authInstance, deps); return authInstance; - })(app); + })(app, heartbeatServiceProvider); }, ComponentType.PUBLIC ) diff --git a/packages/auth/src/platform_browser/auth.test.ts b/packages/auth/src/platform_browser/auth.test.ts index fd4aac82df8..ad104dfceb7 100644 --- a/packages/auth/src/platform_browser/auth.test.ts +++ b/packages/auth/src/platform_browser/auth.test.ts @@ -28,7 +28,7 @@ import { } from '../model/public_types'; import { OperationType } from '../model/enums'; -import { testAuth, testUser } from '../../test/helpers/mock_auth'; +import { FAKE_HEARTBEAT_CONTROLLER_PROVIDER, testAuth, testUser } from '../../test/helpers/mock_auth'; import { AuthImpl, DefaultConfig } from '../core/auth/auth_impl'; import { _initializeAuthInstance } from '../core/auth/initialize'; import { AuthErrorCode } from '../core/errors'; @@ -66,7 +66,7 @@ describe('core/auth/auth_impl', () => { beforeEach(async () => { persistenceStub = sinon.stub(_getInstance(inMemoryPersistence)); - const authImpl = new AuthImpl(FAKE_APP, { + const authImpl = new AuthImpl(FAKE_APP, FAKE_HEARTBEAT_CONTROLLER_PROVIDER, { apiKey: FAKE_APP.options.apiKey!, apiHost: DefaultConfig.API_HOST, apiScheme: DefaultConfig.API_SCHEME, @@ -132,7 +132,7 @@ describe('core/auth/initializeAuth', () => { popupRedirectResolver?: PopupRedirectResolver, authDomain = FAKE_APP.options.authDomain ): Promise { - const auth = new AuthImpl(FAKE_APP, { + const auth = new AuthImpl(FAKE_APP, FAKE_HEARTBEAT_CONTROLLER_PROVIDER, { apiKey: FAKE_APP.options.apiKey!, apiHost: DefaultConfig.API_HOST, apiScheme: DefaultConfig.API_SCHEME, @@ -359,7 +359,7 @@ describe('core/auth/initializeAuth', () => { // Manually initialize auth to make sure no error is thrown, // since the _initializeAuthInstance function floats - const auth = new AuthImpl(FAKE_APP, { + const auth = new AuthImpl(FAKE_APP, FAKE_HEARTBEAT_CONTROLLER_PROVIDER, { apiKey: FAKE_APP.options.apiKey!, apiHost: DefaultConfig.API_HOST, apiScheme: DefaultConfig.API_SCHEME, diff --git a/packages/auth/test/helpers/mock_auth.ts b/packages/auth/test/helpers/mock_auth.ts index 459621cd77d..5b85e162933 100644 --- a/packages/auth/test/helpers/mock_auth.ts +++ b/packages/auth/test/helpers/mock_auth.ts @@ -16,6 +16,7 @@ */ import { FirebaseApp } from '@firebase/app'; +import { Provider } from '@firebase/component'; import { PopupRedirectResolver } from '../../src/model/public_types'; import { debugErrorMap } from '../../src'; @@ -44,6 +45,16 @@ const FAKE_APP: FirebaseApp = { automaticDataCollectionEnabled: false }; +export const FAKE_HEARTBEAT_CONTROLLER = { + getHeartbeatsHeader: async () => '', +}; + +export const FAKE_HEARTBEAT_CONTROLLER_PROVIDER: Provider<'heartbeat'> = { + getImmediate(): typeof FAKE_HEARTBEAT_CONTROLLER { + return FAKE_HEARTBEAT_CONTROLLER; + } +} as unknown as Provider<'heartbeat'>; + export class MockPersistenceLayer extends InMemoryPersistence { lastObjectSet: PersistedBlob | null = null; @@ -62,7 +73,7 @@ export async function testAuth( popupRedirectResolver?: PopupRedirectResolver, persistence = new MockPersistenceLayer() ): Promise { - const auth: TestAuth = new AuthImpl(FAKE_APP, { + const auth: TestAuth = new AuthImpl(FAKE_APP, FAKE_HEARTBEAT_CONTROLLER_PROVIDER, { apiKey: TEST_KEY, authDomain: TEST_AUTH_DOMAIN, apiHost: TEST_HOST, diff --git a/packages/firestore/.idea/runConfigurations/Integration_Tests__Emulator_.xml b/packages/firestore/.idea/runConfigurations/Integration_Tests__Emulator_.xml index e091b2091e9..042f5dbb1a1 100644 --- a/packages/firestore/.idea/runConfigurations/Integration_Tests__Emulator_.xml +++ b/packages/firestore/.idea/runConfigurations/Integration_Tests__Emulator_.xml @@ -10,7 +10,7 @@ bdd - --require babel-register.js --require test/register.ts --require compat/index.node.ts --timeout 5000 + --require babel-register.js --require test/register.ts --timeout 5000 PATTERN test/integration/{,!(browser|lite)/**/}*.test.ts diff --git a/packages/firestore/src/platform/node/grpc_connection.ts b/packages/firestore/src/platform/node/grpc_connection.ts index 463c446769a..8edf2ea1801 100644 --- a/packages/firestore/src/platform/node/grpc_connection.ts +++ b/packages/firestore/src/platform/node/grpc_connection.ts @@ -59,9 +59,13 @@ function createMetadata( metadata.set('X-Firebase-GMPID', appId); } metadata.set('X-Goog-Api-Client', X_GOOG_API_CLIENT_VALUE); - // This header is used to improve routing and project isolation by the + // These headers are used to improve routing and project isolation by the // backend. + // TODO(b/199767712): We are keeping 'Google-Cloud-Resource-Prefix' until Emulators can be + // released with cl/428820046. Currently blocked because Emulators are now built with Java + // 11 from Google3. metadata.set('Google-Cloud-Resource-Prefix', databasePath); + metadata.set('x-goog-request-params', databasePath); return metadata; } diff --git a/packages/installations-compat/package.json b/packages/installations-compat/package.json index bf139b9a4fe..aab9fa68ade 100644 --- a/packages/installations-compat/package.json +++ b/packages/installations-compat/package.json @@ -61,7 +61,6 @@ "@firebase/installations-types": "0.4.0", "@firebase/util": "1.4.3", "@firebase/component": "0.5.10", - "idb": "3.0.2", "tslib": "^2.1.0" } } \ No newline at end of file diff --git a/packages/installations/package.json b/packages/installations/package.json index 2d90a2649c0..9ebff48a7a5 100644 --- a/packages/installations/package.json +++ b/packages/installations/package.json @@ -64,7 +64,6 @@ "dependencies": { "@firebase/util": "1.4.3", "@firebase/component": "0.5.10", - "idb": "3.0.2", "tslib": "^2.1.0" } } \ No newline at end of file diff --git a/packages/installations/src/api/get-id.test.ts b/packages/installations/src/api/get-id.test.ts index 77f14a03cb8..2eeee61cff8 100644 --- a/packages/installations/src/api/get-id.test.ts +++ b/packages/installations/src/api/get-id.test.ts @@ -26,17 +26,14 @@ import { import { getFakeInstallations } from '../testing/fake-generators'; import '../testing/setup'; import { getId } from './get-id'; -import { - FirebaseInstallationsImpl, - AppConfig -} from '../interfaces/installation-impl'; +import { FirebaseInstallationsImpl } from '../interfaces/installation-impl'; const FID = 'disciples-of-the-watch'; describe('getId', () => { let installations: FirebaseInstallationsImpl; let getInstallationEntrySpy: SinonStub< - [AppConfig], + [FirebaseInstallationsImpl], Promise >; diff --git a/packages/installations/src/api/get-id.ts b/packages/installations/src/api/get-id.ts index 027526faee4..589e8b49550 100644 --- a/packages/installations/src/api/get-id.ts +++ b/packages/installations/src/api/get-id.ts @@ -30,7 +30,7 @@ import { Installations } from '../interfaces/public-types'; export async function getId(installations: Installations): Promise { const installationsImpl = installations as FirebaseInstallationsImpl; const { installationEntry, registrationPromise } = await getInstallationEntry( - installationsImpl.appConfig + installationsImpl ); if (registrationPromise) { diff --git a/packages/installations/src/api/get-token.test.ts b/packages/installations/src/api/get-token.test.ts index 430d341b3c2..7b798d2b4ab 100644 --- a/packages/installations/src/api/get-token.test.ts +++ b/packages/installations/src/api/get-token.test.ts @@ -175,7 +175,7 @@ const setupInstallationEntryMap: Map< describe('getToken', () => { let installations: FirebaseInstallationsImpl; let createInstallationRequestSpy: SinonStub< - [AppConfig, InProgressInstallationEntry], + [FirebaseInstallationsImpl, InProgressInstallationEntry], Promise >; let generateAuthTokenRequestSpy: SinonStub< diff --git a/packages/installations/src/api/get-token.ts b/packages/installations/src/api/get-token.ts index ca540019c2c..10e009e4a3a 100644 --- a/packages/installations/src/api/get-token.ts +++ b/packages/installations/src/api/get-token.ts @@ -17,10 +17,7 @@ import { getInstallationEntry } from '../helpers/get-installation-entry'; import { refreshAuthToken } from '../helpers/refresh-auth-token'; -import { - FirebaseInstallationsImpl, - AppConfig -} from '../interfaces/installation-impl'; +import { FirebaseInstallationsImpl } from '../interfaces/installation-impl'; import { Installations } from '../interfaces/public-types'; /** @@ -36,7 +33,7 @@ export async function getToken( forceRefresh = false ): Promise { const installationsImpl = installations as FirebaseInstallationsImpl; - await completeInstallationRegistration(installationsImpl.appConfig); + await completeInstallationRegistration(installationsImpl); // At this point we either have a Registered Installation in the DB, or we've // already thrown an error. @@ -45,9 +42,9 @@ export async function getToken( } async function completeInstallationRegistration( - appConfig: AppConfig + installations: FirebaseInstallationsImpl ): Promise { - const { registrationPromise } = await getInstallationEntry(appConfig); + const { registrationPromise } = await getInstallationEntry(installations); if (registrationPromise) { // A createInstallation request is in progress. Wait until it finishes. diff --git a/packages/installations/src/api/on-id-change.ts b/packages/installations/src/api/on-id-change.ts index e10ebffe0d4..93db7bbf299 100644 --- a/packages/installations/src/api/on-id-change.ts +++ b/packages/installations/src/api/on-id-change.ts @@ -26,7 +26,7 @@ import { Installations } from '../interfaces/public-types'; */ export type IdChangeCallbackFn = (installationId: string) => void; /** - * Unsubscribe a callback function previously added via {@link #IdChangeCallbackFn}. + * Unsubscribe a callback function previously added via {@link IdChangeCallbackFn}. * * @public */ diff --git a/packages/installations/src/functions/config.ts b/packages/installations/src/functions/config.ts index 5d395f68ebd..c8a817d73e5 100644 --- a/packages/installations/src/functions/config.ts +++ b/packages/installations/src/functions/config.ts @@ -36,12 +36,12 @@ const publicFactory: InstanceFactory<'installations'> = ( const app = container.getProvider('app').getImmediate(); // Throws if app isn't configured properly. const appConfig = extractAppConfig(app); - const platformLoggerProvider = _getProvider(app, 'platform-logger'); + const heartbeatServiceProvider = _getProvider(app, 'heartbeat'); const installationsImpl: FirebaseInstallationsImpl = { app, appConfig, - platformLoggerProvider, + heartbeatServiceProvider, _delete: () => Promise.resolve() }; return installationsImpl; diff --git a/packages/installations/src/functions/create-installation-request.test.ts b/packages/installations/src/functions/create-installation-request.test.ts index 67f00585595..4aabd54b056 100644 --- a/packages/installations/src/functions/create-installation-request.test.ts +++ b/packages/installations/src/functions/create-installation-request.test.ts @@ -19,13 +19,13 @@ import { FirebaseError } from '@firebase/util'; import { expect } from 'chai'; import { SinonStub, stub } from 'sinon'; import { CreateInstallationResponse } from '../interfaces/api-response'; -import { AppConfig } from '../interfaces/installation-impl'; +import { FirebaseInstallationsImpl } from '../interfaces/installation-impl'; import { InProgressInstallationEntry, RequestStatus } from '../interfaces/installation-entry'; import { compareHeaders } from '../testing/compare-headers'; -import { getFakeAppConfig } from '../testing/fake-generators'; +import { getFakeInstallations } from '../testing/fake-generators'; import '../testing/setup'; import { INSTALLATIONS_API_URL, @@ -38,13 +38,13 @@ import { createInstallationRequest } from './create-installation-request'; const FID = 'defenders-of-the-faith'; describe('createInstallationRequest', () => { - let appConfig: AppConfig; + let fakeInstallations: FirebaseInstallationsImpl; let fetchSpy: SinonStub<[RequestInfo, RequestInit?], Promise>; let inProgressInstallationEntry: InProgressInstallationEntry; let response: CreateInstallationResponse; beforeEach(() => { - appConfig = getFakeAppConfig(); + fakeInstallations = getFakeInstallations(); inProgressInstallationEntry = { fid: FID, @@ -71,7 +71,7 @@ describe('createInstallationRequest', () => { it('registers a pending InstallationEntry', async () => { const registeredInstallationEntry = await createInstallationRequest( - appConfig, + fakeInstallations, inProgressInstallationEntry ); expect(registeredInstallationEntry.registrationStatus).to.equal( @@ -83,12 +83,13 @@ describe('createInstallationRequest', () => { const expectedHeaders = new Headers({ 'Content-Type': 'application/json', Accept: 'application/json', - 'x-goog-api-key': 'apiKey' + 'x-goog-api-key': 'apiKey', + 'x-firebase-client': 'a/1.2.3 b/2.3.4' }); const expectedBody = { fid: FID, authVersion: INTERNAL_AUTH_VERSION, - appId: appConfig.appId, + appId: fakeInstallations.appConfig.appId, sdkVersion: PACKAGE_VERSION }; const expectedRequest: RequestInit = { @@ -98,7 +99,10 @@ describe('createInstallationRequest', () => { }; const expectedEndpoint = `${INSTALLATIONS_API_URL}/projects/projectId/installations`; - await createInstallationRequest(appConfig, inProgressInstallationEntry); + await createInstallationRequest( + fakeInstallations, + inProgressInstallationEntry + ); expect(fetchSpy).to.be.calledOnceWith(expectedEndpoint, expectedRequest); const actualHeaders = fetchSpy.lastCall.lastArg.headers; compareHeaders(expectedHeaders, actualHeaders); @@ -117,7 +121,7 @@ describe('createInstallationRequest', () => { fetchSpy.resolves(new Response(JSON.stringify(response))); const registeredInstallationEntry = await createInstallationRequest( - appConfig, + fakeInstallations, inProgressInstallationEntry ); expect(registeredInstallationEntry.fid).to.equal(FID); @@ -138,7 +142,10 @@ describe('createInstallationRequest', () => { ); await expect( - createInstallationRequest(appConfig, inProgressInstallationEntry) + createInstallationRequest( + fakeInstallations, + inProgressInstallationEntry + ) ).to.be.rejectedWith(FirebaseError); }); @@ -157,7 +164,10 @@ describe('createInstallationRequest', () => { fetchSpy.onCall(1).resolves(new Response(JSON.stringify(response))); await expect( - createInstallationRequest(appConfig, inProgressInstallationEntry) + createInstallationRequest( + fakeInstallations, + inProgressInstallationEntry + ) ).to.be.fulfilled; expect(fetchSpy).to.be.calledTwice; }); diff --git a/packages/installations/src/functions/create-installation-request.ts b/packages/installations/src/functions/create-installation-request.ts index fe8242613f6..387284ceef6 100644 --- a/packages/installations/src/functions/create-installation-request.ts +++ b/packages/installations/src/functions/create-installation-request.ts @@ -29,15 +29,27 @@ import { getInstallationsEndpoint, retryIfServerError } from './common'; -import { AppConfig } from '../interfaces/installation-impl'; +import { FirebaseInstallationsImpl } from '../interfaces/installation-impl'; export async function createInstallationRequest( - appConfig: AppConfig, + { appConfig, heartbeatServiceProvider }: FirebaseInstallationsImpl, { fid }: InProgressInstallationEntry ): Promise { const endpoint = getInstallationsEndpoint(appConfig); const headers = getHeaders(appConfig); + + // If heartbeat service exists, add the heartbeat string to the header. + const heartbeatService = heartbeatServiceProvider.getImmediate({ + optional: true + }); + if (heartbeatService) { + const heartbeatsHeader = await heartbeatService.getHeartbeatsHeader(); + if (heartbeatsHeader) { + headers.append('x-firebase-client', heartbeatsHeader); + } + } + const body = { fid, authVersion: INTERNAL_AUTH_VERSION, diff --git a/packages/installations/src/functions/generate-auth-token-request.test.ts b/packages/installations/src/functions/generate-auth-token-request.test.ts index 01be300d6f8..238dc1738ba 100644 --- a/packages/installations/src/functions/generate-auth-token-request.test.ts +++ b/packages/installations/src/functions/generate-auth-token-request.test.ts @@ -91,7 +91,8 @@ describe('generateAuthTokenRequest', () => { }); const expectedBody = { installation: { - sdkVersion: PACKAGE_VERSION + sdkVersion: PACKAGE_VERSION, + appId: installations.appConfig.appId } }; const expectedRequest: RequestInit = { diff --git a/packages/installations/src/functions/generate-auth-token-request.ts b/packages/installations/src/functions/generate-auth-token-request.ts index b9fbe77ac95..f229eb16ff8 100644 --- a/packages/installations/src/functions/generate-auth-token-request.ts +++ b/packages/installations/src/functions/generate-auth-token-request.ts @@ -34,24 +34,28 @@ import { } from '../interfaces/installation-impl'; export async function generateAuthTokenRequest( - { appConfig, platformLoggerProvider }: FirebaseInstallationsImpl, + { appConfig, heartbeatServiceProvider }: FirebaseInstallationsImpl, installationEntry: RegisteredInstallationEntry ): Promise { const endpoint = getGenerateAuthTokenEndpoint(appConfig, installationEntry); const headers = getHeadersWithAuth(appConfig, installationEntry); - // If platform logger exists, add the platform info string to the header. - const platformLogger = platformLoggerProvider.getImmediate({ + // If heartbeat service exists, add the heartbeat string to the header. + const heartbeatService = heartbeatServiceProvider.getImmediate({ optional: true }); - if (platformLogger) { - headers.append('x-firebase-client', platformLogger.getPlatformInfoString()); + if (heartbeatService) { + const heartbeatsHeader = await heartbeatService.getHeartbeatsHeader(); + if (heartbeatsHeader) { + headers.append('x-firebase-client', heartbeatsHeader); + } } const body = { installation: { - sdkVersion: PACKAGE_VERSION + sdkVersion: PACKAGE_VERSION, + appId: appConfig.appId } }; diff --git a/packages/installations/src/helpers/get-installation-entry.test.ts b/packages/installations/src/helpers/get-installation-entry.test.ts index e8531d442d2..ed08329c8ad 100644 --- a/packages/installations/src/helpers/get-installation-entry.test.ts +++ b/packages/installations/src/helpers/get-installation-entry.test.ts @@ -18,14 +18,17 @@ import { AssertionError, expect } from 'chai'; import { SinonFakeTimers, SinonStub, stub, useFakeTimers } from 'sinon'; import * as createInstallationRequestModule from '../functions/create-installation-request'; -import { AppConfig } from '../interfaces/installation-impl'; +import { + AppConfig, + FirebaseInstallationsImpl +} from '../interfaces/installation-impl'; import { InProgressInstallationEntry, RegisteredInstallationEntry, RequestStatus, UnregisteredInstallationEntry } from '../interfaces/installation-entry'; -import { getFakeAppConfig } from '../testing/fake-generators'; +import { getFakeInstallations } from '../testing/fake-generators'; import '../testing/setup'; import { ERROR_FACTORY, ErrorCode } from '../util/errors'; import { sleep } from '../util/sleep'; @@ -37,15 +40,17 @@ const FID = 'cry-of-the-black-birds'; describe('getInstallationEntry', () => { let clock: SinonFakeTimers; + let fakeInstallations: FirebaseInstallationsImpl; let appConfig: AppConfig; let createInstallationRequestSpy: SinonStub< - [AppConfig, InProgressInstallationEntry], + [FirebaseInstallationsImpl, InProgressInstallationEntry], Promise >; beforeEach(() => { clock = useFakeTimers({ now: 1_000_000 }); - appConfig = getFakeAppConfig(); + fakeInstallations = getFakeInstallations(); + appConfig = fakeInstallations.appConfig; createInstallationRequestSpy = stub( createInstallationRequestModule, 'createInstallationRequest' @@ -78,7 +83,7 @@ describe('getInstallationEntry', () => { const oldDbEntry = await get(appConfig); expect(oldDbEntry).to.be.undefined; - const { installationEntry } = await getInstallationEntry(appConfig); + const { installationEntry } = await getInstallationEntry(fakeInstallations); const newDbEntry = await get(appConfig); expect(newDbEntry).to.deep.equal(installationEntry); @@ -90,7 +95,7 @@ describe('getInstallationEntry', () => { const oldDbEntry = await get(appConfig); expect(oldDbEntry).to.be.undefined; - const { installationEntry } = await getInstallationEntry(appConfig); + const { installationEntry } = await getInstallationEntry(fakeInstallations); const newDbEntry = await get(appConfig); expect(newDbEntry).to.deep.equal(installationEntry); @@ -98,7 +103,7 @@ describe('getInstallationEntry', () => { it('saves the InstallationEntry in the database when registration completes', async () => { const { installationEntry, registrationPromise } = - await getInstallationEntry(appConfig); + await getInstallationEntry(fakeInstallations); expect(installationEntry.registrationStatus).to.equal( RequestStatus.IN_PROGRESS ); @@ -126,7 +131,7 @@ describe('getInstallationEntry', () => { }); const { installationEntry, registrationPromise } = - await getInstallationEntry(appConfig); + await getInstallationEntry(fakeInstallations); expect(installationEntry.registrationStatus).to.equal( RequestStatus.IN_PROGRESS ); @@ -154,7 +159,7 @@ describe('getInstallationEntry', () => { }); const { installationEntry, registrationPromise } = - await getInstallationEntry(appConfig); + await getInstallationEntry(fakeInstallations); expect(installationEntry.registrationStatus).to.equal( RequestStatus.IN_PROGRESS ); @@ -170,8 +175,12 @@ describe('getInstallationEntry', () => { }); it('returns the same FID on subsequent calls', async () => { - const { installationEntry: entry1 } = await getInstallationEntry(appConfig); - const { installationEntry: entry2 } = await getInstallationEntry(appConfig); + const { installationEntry: entry1 } = await getInstallationEntry( + fakeInstallations + ); + const { installationEntry: entry2 } = await getInstallationEntry( + fakeInstallations + ); expect(entry1.fid).to.equal(entry2.fid); }); @@ -187,7 +196,7 @@ describe('getInstallationEntry', () => { it('returns a new pending InstallationEntry and triggers createInstallation', async () => { const { installationEntry, registrationPromise } = - await getInstallationEntry(appConfig); + await getInstallationEntry(fakeInstallations); if (installationEntry.registrationStatus !== RequestStatus.IN_PROGRESS) { throw new AssertionError('InstallationEntry is not IN_PROGRESS.'); @@ -208,7 +217,9 @@ describe('getInstallationEntry', () => { it('returns a new unregistered InstallationEntry if app is offline', async () => { stub(navigator, 'onLine').value(false); - const { installationEntry } = await getInstallationEntry(appConfig); + const { installationEntry } = await getInstallationEntry( + fakeInstallations + ); expect(installationEntry).to.deep.equal({ fid: FID, @@ -219,18 +230,18 @@ describe('getInstallationEntry', () => { }); it('does not trigger createInstallation REST call on subsequent calls', async () => { - await getInstallationEntry(appConfig); - await getInstallationEntry(appConfig); + await getInstallationEntry(fakeInstallations); + await getInstallationEntry(fakeInstallations); expect(createInstallationRequestSpy).to.be.calledOnce; }); it('returns a registrationPromise on subsequent calls before initial promise resolves', async () => { const { registrationPromise: promise1 } = await getInstallationEntry( - appConfig + fakeInstallations ); const { registrationPromise: promise2 } = await getInstallationEntry( - appConfig + fakeInstallations ); expect(createInstallationRequestSpy).to.be.calledOnce; @@ -240,7 +251,7 @@ describe('getInstallationEntry', () => { it('does not return a registrationPromise on subsequent calls after initial promise resolves', async () => { const { registrationPromise: promise1 } = await getInstallationEntry( - appConfig + fakeInstallations ); expect(promise1).to.be.an.instanceOf(Promise); @@ -248,7 +259,7 @@ describe('getInstallationEntry', () => { await expect(promise1).to.be.fulfilled; const { registrationPromise: promise2 } = await getInstallationEntry( - appConfig + fakeInstallations ); expect(promise2).to.be.undefined; @@ -266,7 +277,8 @@ describe('getInstallationEntry', () => { // FID generation fails. generateInstallationEntrySpy.returns(generateFidModule.INVALID_FID); - const getInstallationEntryPromise = getInstallationEntry(appConfig); + const getInstallationEntryPromise = + getInstallationEntry(fakeInstallations); const { installationEntry, registrationPromise } = await getInstallationEntryPromise; @@ -287,7 +299,7 @@ describe('getInstallationEntry', () => { it('returns a pending InstallationEntry and triggers createInstallation', async () => { const { installationEntry, registrationPromise } = - await getInstallationEntry(appConfig); + await getInstallationEntry(fakeInstallations); if (installationEntry.registrationStatus !== RequestStatus.IN_PROGRESS) { throw new AssertionError('InstallationEntry is not IN_PROGRESS.'); @@ -306,7 +318,9 @@ describe('getInstallationEntry', () => { it('returns the same InstallationEntry if the app is offline', async () => { stub(navigator, 'onLine').value(false); - const { installationEntry } = await getInstallationEntry(appConfig); + const { installationEntry } = await getInstallationEntry( + fakeInstallations + ); expect(installationEntry).to.deep.equal({ fid: FID, @@ -329,7 +343,9 @@ describe('getInstallationEntry', () => { it("returns the same InstallationEntry if the request hasn't timed out", async () => { clock.now = 1_001_000; // One second after the request was initiated. - const { installationEntry } = await getInstallationEntry(appConfig); + const { installationEntry } = await getInstallationEntry( + fakeInstallations + ); expect(installationEntry).to.deep.equal({ fid: FID, @@ -347,7 +363,7 @@ describe('getInstallationEntry', () => { true /* Needed to allow the createInstallation request to complete. */ }); - const installationEntryPromise = getInstallationEntry(appConfig); + const installationEntryPromise = getInstallationEntry(fakeInstallations); // The pending request fails after a while. clock.tick(3000); @@ -389,7 +405,7 @@ describe('getInstallationEntry', () => { true /* Needed to allow the createInstallation request to complete. */ }); - const installationEntryPromise = getInstallationEntry(appConfig); + const installationEntryPromise = getInstallationEntry(fakeInstallations); // The pending request fails after a while. clock.tick(3000); @@ -418,7 +434,9 @@ describe('getInstallationEntry', () => { it('returns a new pending InstallationEntry and triggers createInstallation if the request had already timed out', async () => { clock.now = 1_015_000; // Fifteen seconds after the request was initiated. - const { installationEntry } = await getInstallationEntry(appConfig); + const { installationEntry } = await getInstallationEntry( + fakeInstallations + ); expect(installationEntry).to.deep.equal({ fid: FID, @@ -432,7 +450,9 @@ describe('getInstallationEntry', () => { stub(navigator, 'onLine').value(false); clock.now = 1_015_000; // Fifteen seconds after the request was initiated. - const { installationEntry } = await getInstallationEntry(appConfig); + const { installationEntry } = await getInstallationEntry( + fakeInstallations + ); expect(installationEntry).to.deep.equal({ fid: FID, @@ -454,7 +474,9 @@ describe('getInstallationEntry', () => { }); it('returns the InstallationEntry from the database', async () => { - const { installationEntry } = await getInstallationEntry(appConfig); + const { installationEntry } = await getInstallationEntry( + fakeInstallations + ); expect(installationEntry).to.deep.equal({ fid: FID, diff --git a/packages/installations/src/helpers/get-installation-entry.ts b/packages/installations/src/helpers/get-installation-entry.ts index fb91642d29d..1de700910ff 100644 --- a/packages/installations/src/helpers/get-installation-entry.ts +++ b/packages/installations/src/helpers/get-installation-entry.ts @@ -16,7 +16,10 @@ */ import { createInstallationRequest } from '../functions/create-installation-request'; -import { AppConfig } from '../interfaces/installation-impl'; +import { + AppConfig, + FirebaseInstallationsImpl +} from '../interfaces/installation-impl'; import { InProgressInstallationEntry, InstallationEntry, @@ -40,14 +43,14 @@ export interface InstallationEntryWithRegistrationPromise { * Also triggers a registration request if it is necessary and possible. */ export async function getInstallationEntry( - appConfig: AppConfig + installations: FirebaseInstallationsImpl ): Promise { let registrationPromise: Promise | undefined; - const installationEntry = await update(appConfig, oldEntry => { + const installationEntry = await update(installations.appConfig, oldEntry => { const installationEntry = updateOrCreateInstallationEntry(oldEntry); const entryWithPromise = triggerRegistrationIfNecessary( - appConfig, + installations, installationEntry ); registrationPromise = entryWithPromise.registrationPromise; @@ -88,7 +91,7 @@ function updateOrCreateInstallationEntry( * to be registered. */ function triggerRegistrationIfNecessary( - appConfig: AppConfig, + installations: FirebaseInstallationsImpl, installationEntry: InstallationEntry ): InstallationEntryWithRegistrationPromise { if (installationEntry.registrationStatus === RequestStatus.NOT_STARTED) { @@ -110,7 +113,7 @@ function triggerRegistrationIfNecessary( registrationTime: Date.now() }; const registrationPromise = registerInstallation( - appConfig, + installations, inProgressEntry ); return { installationEntry: inProgressEntry, registrationPromise }; @@ -119,7 +122,7 @@ function triggerRegistrationIfNecessary( ) { return { installationEntry, - registrationPromise: waitUntilFidRegistration(appConfig) + registrationPromise: waitUntilFidRegistration(installations) }; } else { return { installationEntry }; @@ -128,23 +131,23 @@ function triggerRegistrationIfNecessary( /** This will be executed only once for each new Firebase Installation. */ async function registerInstallation( - appConfig: AppConfig, + installations: FirebaseInstallationsImpl, installationEntry: InProgressInstallationEntry ): Promise { try { const registeredInstallationEntry = await createInstallationRequest( - appConfig, + installations, installationEntry ); - return set(appConfig, registeredInstallationEntry); + return set(installations.appConfig, registeredInstallationEntry); } catch (e) { if (isServerError(e) && e.customData.serverCode === 409) { // Server returned a "FID can not be used" error. // Generate a new ID next time. - await remove(appConfig); + await remove(installations.appConfig); } else { // Registration failed. Set FID as not registered. - await set(appConfig, { + await set(installations.appConfig, { fid: installationEntry.fid, registrationStatus: RequestStatus.NOT_STARTED }); @@ -155,24 +158,26 @@ async function registerInstallation( /** Call if FID registration is pending in another request. */ async function waitUntilFidRegistration( - appConfig: AppConfig + installations: FirebaseInstallationsImpl ): Promise { // Unfortunately, there is no way of reliably observing when a value in // IndexedDB changes (yet, see https://github.com/WICG/indexed-db-observers), // so we need to poll. - let entry: InstallationEntry = await updateInstallationRequest(appConfig); + let entry: InstallationEntry = await updateInstallationRequest( + installations.appConfig + ); while (entry.registrationStatus === RequestStatus.IN_PROGRESS) { // createInstallation request still in progress. await sleep(100); - entry = await updateInstallationRequest(appConfig); + entry = await updateInstallationRequest(installations.appConfig); } if (entry.registrationStatus === RequestStatus.NOT_STARTED) { // The request timed out or failed in a different call. Try again. const { installationEntry, registrationPromise } = - await getInstallationEntry(appConfig); + await getInstallationEntry(installations); if (registrationPromise) { return registrationPromise; diff --git a/packages/installations/src/helpers/idb-manager.ts b/packages/installations/src/helpers/idb-manager.ts index bc30563fa06..6c502ae4bb9 100644 --- a/packages/installations/src/helpers/idb-manager.ts +++ b/packages/installations/src/helpers/idb-manager.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { DB, openDb } from 'idb'; +import { DBWrapper, openDB } from '@firebase/util'; import { AppConfig } from '../interfaces/installation-impl'; import { InstallationEntry } from '../interfaces/installation-entry'; import { getKey } from '../util/get-key'; @@ -25,18 +25,18 @@ const DATABASE_NAME = 'firebase-installations-database'; const DATABASE_VERSION = 1; const OBJECT_STORE_NAME = 'firebase-installations-store'; -let dbPromise: Promise | null = null; -function getDbPromise(): Promise { +let dbPromise: Promise | null = null; +function getDbPromise(): Promise { if (!dbPromise) { - dbPromise = openDb(DATABASE_NAME, DATABASE_VERSION, upgradeDB => { + dbPromise = openDB(DATABASE_NAME, DATABASE_VERSION, (db, oldVersion) => { // We don't use 'break' in this switch statement, the fall-through // behavior is what we want, because if there are multiple versions between // the old version and the current version, we want ALL the migrations // that correspond to those versions to run, not only the last one. // eslint-disable-next-line default-case - switch (upgradeDB.oldVersion) { + switch (oldVersion) { case 0: - upgradeDB.createObjectStore(OBJECT_STORE_NAME); + db.createObjectStore(OBJECT_STORE_NAME); } }); } @@ -52,7 +52,7 @@ export async function get( return db .transaction(OBJECT_STORE_NAME) .objectStore(OBJECT_STORE_NAME) - .get(key); + .get(key) as Promise; } /** Assigns or overwrites the record for the given key with the given value. */ @@ -64,7 +64,7 @@ export async function set( const db = await getDbPromise(); const tx = db.transaction(OBJECT_STORE_NAME, 'readwrite'); const objectStore = tx.objectStore(OBJECT_STORE_NAME); - const oldValue = await objectStore.get(key); + const oldValue = (await objectStore.get(key)) as InstallationEntry; await objectStore.put(value, key); await tx.complete; @@ -98,7 +98,9 @@ export async function update( const db = await getDbPromise(); const tx = db.transaction(OBJECT_STORE_NAME, 'readwrite'); const store = tx.objectStore(OBJECT_STORE_NAME); - const oldValue: InstallationEntry | undefined = await store.get(key); + const oldValue: InstallationEntry | undefined = (await store.get( + key + )) as InstallationEntry; const newValue = updateFn(oldValue); if (newValue === undefined) { diff --git a/packages/installations/src/interfaces/installation-impl.ts b/packages/installations/src/interfaces/installation-impl.ts index 2598275f9ad..53ed9b86d4f 100644 --- a/packages/installations/src/interfaces/installation-impl.ts +++ b/packages/installations/src/interfaces/installation-impl.ts @@ -23,7 +23,7 @@ export interface FirebaseInstallationsImpl extends Installations, _FirebaseService { readonly appConfig: AppConfig; - readonly platformLoggerProvider: Provider<'platform-logger'>; + readonly heartbeatServiceProvider: Provider<'heartbeat'>; } export interface AppConfig { diff --git a/packages/installations/src/testing/fake-generators.ts b/packages/installations/src/testing/fake-generators.ts index 419e0151e43..6309228d72b 100644 --- a/packages/installations/src/testing/fake-generators.ts +++ b/packages/installations/src/testing/fake-generators.ts @@ -53,8 +53,11 @@ export function getFakeInstallations(): FirebaseInstallationsImpl { const container = new ComponentContainer('test'); container.addComponent( new Component( - 'platform-logger', - () => ({ getPlatformInfoString: () => 'a/1.2.3 b/2.3.4' }), + 'heartbeat', + () => ({ + getHeartbeatsHeader: () => Promise.resolve('a/1.2.3 b/2.3.4'), + triggerHeartbeat: () => Promise.resolve() + }), ComponentType.PRIVATE ) ); @@ -62,7 +65,7 @@ export function getFakeInstallations(): FirebaseInstallationsImpl { return { app: getFakeApp(), appConfig: getFakeAppConfig(), - platformLoggerProvider: container.getProvider('platform-logger'), + heartbeatServiceProvider: container.getProvider('heartbeat'), _delete: () => { return Promise.resolve(); } diff --git a/packages/messaging/package.json b/packages/messaging/package.json index 752910df4fe..189321d60d2 100644 --- a/packages/messaging/package.json +++ b/packages/messaging/package.json @@ -56,7 +56,6 @@ "@firebase/messaging-interop-types": "0.1.0", "@firebase/util": "1.4.3", "@firebase/component": "0.5.10", - "idb": "3.0.2", "tslib": "^2.1.0" }, "devDependencies": { diff --git a/packages/messaging/src/helpers/migrate-old-database.test.ts b/packages/messaging/src/helpers/migrate-old-database.test.ts index 020295ca2fd..2ae03c7545d 100644 --- a/packages/messaging/src/helpers/migrate-old-database.test.ts +++ b/packages/messaging/src/helpers/migrate-old-database.test.ts @@ -28,7 +28,7 @@ import { FakePushSubscription } from '../testing/fakes/service-worker'; import { base64ToArray } from './array-base64-translator'; import { expect } from 'chai'; import { getFakeTokenDetails } from '../testing/fakes/token-details'; -import { openDb } from 'idb'; +import { openDB } from '@firebase/util'; describe('migrateOldDb', () => { it("does nothing if old DB didn't exist", async () => { @@ -179,14 +179,11 @@ describe('migrateOldDb', () => { }); async function put(version: number, value: object): Promise { - const db = await openDb('fcm_token_details_db', version, upgradeDb => { - if (upgradeDb.oldVersion === 0) { - const objectStore = upgradeDb.createObjectStore( - 'fcm_token_object_Store', - { - keyPath: 'swScope' - } - ); + const db = await openDB('fcm_token_details_db', version, (db, oldVersion) => { + if (oldVersion === 0) { + const objectStore = db.createObjectStore('fcm_token_object_Store', { + keyPath: 'swScope' + }); objectStore.createIndex('fcmSenderId', 'fcmSenderId', { unique: false }); diff --git a/packages/messaging/src/helpers/migrate-old-database.ts b/packages/messaging/src/helpers/migrate-old-database.ts index f7a5977502e..0117faca131 100644 --- a/packages/messaging/src/helpers/migrate-old-database.ts +++ b/packages/messaging/src/helpers/migrate-old-database.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { deleteDb, openDb } from 'idb'; +import { deleteDB, openDB } from '@firebase/util'; import { TokenDetails } from '../interfaces/token-details'; import { arrayToBase64 } from './array-base64-translator'; @@ -88,83 +88,87 @@ export async function migrateOldDatabase( let tokenDetails: TokenDetails | null = null; - const db = await openDb(OLD_DB_NAME, OLD_DB_VERSION, async db => { - if (db.oldVersion < 2) { - // Database too old, skip migration. - return; - } - - if (!db.objectStoreNames.contains(OLD_OBJECT_STORE_NAME)) { - // Database did not exist. Nothing to do. - return; - } - - const objectStore = db.transaction.objectStore(OLD_OBJECT_STORE_NAME); - const value = await objectStore.index('fcmSenderId').get(senderId); - await objectStore.clear(); + const db = await openDB( + OLD_DB_NAME, + OLD_DB_VERSION, + async (db, oldVersion, newVersion, upgradeTransaction) => { + if (oldVersion < 2) { + // Database too old, skip migration. + return; + } - if (!value) { - // No entry in the database, nothing to migrate. - return; - } + if (!db.objectStoreNames.contains(OLD_OBJECT_STORE_NAME)) { + // Database did not exist. Nothing to do. + return; + } - if (db.oldVersion === 2) { - const oldDetails = value as V2TokenDetails; + const objectStore = upgradeTransaction.objectStore(OLD_OBJECT_STORE_NAME); + const value = await objectStore.index('fcmSenderId').get(senderId); + await objectStore.clear(); - if (!oldDetails.auth || !oldDetails.p256dh || !oldDetails.endpoint) { + if (!value) { + // No entry in the database, nothing to migrate. return; } - tokenDetails = { - token: oldDetails.fcmToken, - createTime: oldDetails.createTime ?? Date.now(), - subscriptionOptions: { - auth: oldDetails.auth, - p256dh: oldDetails.p256dh, - endpoint: oldDetails.endpoint, - swScope: oldDetails.swScope, - vapidKey: - typeof oldDetails.vapidKey === 'string' - ? oldDetails.vapidKey - : arrayToBase64(oldDetails.vapidKey) - } - }; - } else if (db.oldVersion === 3) { - const oldDetails = value as V3TokenDetails; - - tokenDetails = { - token: oldDetails.fcmToken, - createTime: oldDetails.createTime, - subscriptionOptions: { - auth: arrayToBase64(oldDetails.auth), - p256dh: arrayToBase64(oldDetails.p256dh), - endpoint: oldDetails.endpoint, - swScope: oldDetails.swScope, - vapidKey: arrayToBase64(oldDetails.vapidKey) - } - }; - } else if (db.oldVersion === 4) { - const oldDetails = value as V4TokenDetails; - - tokenDetails = { - token: oldDetails.fcmToken, - createTime: oldDetails.createTime, - subscriptionOptions: { - auth: arrayToBase64(oldDetails.auth), - p256dh: arrayToBase64(oldDetails.p256dh), - endpoint: oldDetails.endpoint, - swScope: oldDetails.swScope, - vapidKey: arrayToBase64(oldDetails.vapidKey) + if (oldVersion === 2) { + const oldDetails = value as V2TokenDetails; + + if (!oldDetails.auth || !oldDetails.p256dh || !oldDetails.endpoint) { + return; } - }; + + tokenDetails = { + token: oldDetails.fcmToken, + createTime: oldDetails.createTime ?? Date.now(), + subscriptionOptions: { + auth: oldDetails.auth, + p256dh: oldDetails.p256dh, + endpoint: oldDetails.endpoint, + swScope: oldDetails.swScope, + vapidKey: + typeof oldDetails.vapidKey === 'string' + ? oldDetails.vapidKey + : arrayToBase64(oldDetails.vapidKey) + } + }; + } else if (oldVersion === 3) { + const oldDetails = value as V3TokenDetails; + + tokenDetails = { + token: oldDetails.fcmToken, + createTime: oldDetails.createTime, + subscriptionOptions: { + auth: arrayToBase64(oldDetails.auth), + p256dh: arrayToBase64(oldDetails.p256dh), + endpoint: oldDetails.endpoint, + swScope: oldDetails.swScope, + vapidKey: arrayToBase64(oldDetails.vapidKey) + } + }; + } else if (oldVersion === 4) { + const oldDetails = value as V4TokenDetails; + + tokenDetails = { + token: oldDetails.fcmToken, + createTime: oldDetails.createTime, + subscriptionOptions: { + auth: arrayToBase64(oldDetails.auth), + p256dh: arrayToBase64(oldDetails.p256dh), + endpoint: oldDetails.endpoint, + swScope: oldDetails.swScope, + vapidKey: arrayToBase64(oldDetails.vapidKey) + } + }; + } } - }); + ); db.close(); // Delete all old databases. - await deleteDb(OLD_DB_NAME); - await deleteDb('fcm_vapid_details_db'); - await deleteDb('undefined'); + await deleteDB(OLD_DB_NAME); + await deleteDB('fcm_vapid_details_db'); + await deleteDB('undefined'); return checkTokenDetails(tokenDetails) ? tokenDetails : null; } diff --git a/packages/messaging/src/internals/idb-manager.ts b/packages/messaging/src/internals/idb-manager.ts index 4ddebf5ae96..1aa82734757 100644 --- a/packages/messaging/src/internals/idb-manager.ts +++ b/packages/messaging/src/internals/idb-manager.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { DB, deleteDb, openDb } from 'idb'; +import { DBWrapper, deleteDB, openDB } from '@firebase/util'; import { FirebaseInternalDependencies } from '../interfaces/internal-dependencies'; import { TokenDetails } from '../interfaces/token-details'; @@ -26,19 +26,23 @@ export const DATABASE_NAME = 'firebase-messaging-database'; const DATABASE_VERSION = 1; const OBJECT_STORE_NAME = 'firebase-messaging-store'; -let dbPromise: Promise | null = null; -function getDbPromise(): Promise { +let dbPromise: Promise | null = null; +function getDbPromise(): Promise { if (!dbPromise) { - dbPromise = openDb(DATABASE_NAME, DATABASE_VERSION, upgradeDb => { - // We don't use 'break' in this switch statement, the fall-through behavior is what we want, - // because if there are multiple versions between the old version and the current version, we - // want ALL the migrations that correspond to those versions to run, not only the last one. - // eslint-disable-next-line default-case - switch (upgradeDb.oldVersion) { - case 0: - upgradeDb.createObjectStore(OBJECT_STORE_NAME); + dbPromise = openDB( + DATABASE_NAME, + DATABASE_VERSION, + (upgradeDb, oldVersion) => { + // We don't use 'break' in this switch statement, the fall-through behavior is what we want, + // because if there are multiple versions between the old version and the current version, we + // want ALL the migrations that correspond to those versions to run, not only the last one. + // eslint-disable-next-line default-case + switch (oldVersion) { + case 0: + upgradeDb.createObjectStore(OBJECT_STORE_NAME); + } } - }); + ); } return dbPromise; } @@ -49,10 +53,10 @@ export async function dbGet( ): Promise { const key = getKey(firebaseDependencies); const db = await getDbPromise(); - const tokenDetails = await db + const tokenDetails = (await db .transaction(OBJECT_STORE_NAME) .objectStore(OBJECT_STORE_NAME) - .get(key); + .get(key)) as TokenDetails; if (tokenDetails) { return tokenDetails; @@ -96,7 +100,7 @@ export async function dbRemove( export async function dbDelete(): Promise { if (dbPromise) { (await dbPromise).close(); - await deleteDb(DATABASE_NAME); + await deleteDB(DATABASE_NAME); dbPromise = null; } } diff --git a/packages/messaging/src/testing/setup.ts b/packages/messaging/src/testing/setup.ts index eb4802a11a1..7aa969119c6 100644 --- a/packages/messaging/src/testing/setup.ts +++ b/packages/messaging/src/testing/setup.ts @@ -19,7 +19,7 @@ import chaiAsPromised from 'chai-as-promised'; import sinonChai from 'sinon-chai'; import { dbDelete } from '../internals/idb-manager'; -import { deleteDb } from 'idb'; +import { deleteDB } from '@firebase/util'; import { restore } from 'sinon'; import { use } from 'chai'; @@ -29,5 +29,5 @@ use(sinonChai); afterEach(async () => { restore(); await dbDelete(); - await deleteDb('fcm_token_details_db'); + await deleteDB('fcm_token_details_db'); }); diff --git a/packages/util/index.node.ts b/packages/util/index.node.ts index 8dace3b8e1e..e27c304145c 100644 --- a/packages/util/index.node.ts +++ b/packages/util/index.node.ts @@ -39,3 +39,6 @@ export * from './src/utf8'; export * from './src/exponential_backoff'; export * from './src/formatters'; export * from './src/compat'; +// This can't be used in Node but it will cause errors if libraries import +// these methods and they aren't here. +export * from './src/indexeddb'; diff --git a/packages/util/index.ts b/packages/util/index.ts index 00d661734b8..0cf518fbd81 100644 --- a/packages/util/index.ts +++ b/packages/util/index.ts @@ -34,3 +34,4 @@ export * from './src/utf8'; export * from './src/exponential_backoff'; export * from './src/formatters'; export * from './src/compat'; +export * from './src/indexeddb'; diff --git a/packages/util/src/indexeddb.ts b/packages/util/src/indexeddb.ts new file mode 100644 index 00000000000..b2d4546f18e --- /dev/null +++ b/packages/util/src/indexeddb.ts @@ -0,0 +1,174 @@ +/** + * @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. + */ + +function promisifyRequest( + request: IDBRequest, + errorMessage: string +): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = event => { + resolve((event.target as IDBRequest).result); + }; + request.onerror = event => { + reject(`${errorMessage}: ${(event.target as IDBRequest).error?.message}`); + }; + }); +} + +export class DBWrapper { + objectStoreNames: DOMStringList; + constructor(private _db: IDBDatabase) { + this.objectStoreNames = this._db.objectStoreNames; + } + transaction( + storeNames: string[] | string, + mode?: IDBTransactionMode + ): TransactionWrapper { + return new TransactionWrapper( + this._db.transaction.call(this._db, storeNames, mode) + ); + } + createObjectStore( + storeName: string, + options?: IDBObjectStoreParameters + ): ObjectStoreWrapper { + return new ObjectStoreWrapper( + this._db.createObjectStore(storeName, options) + ); + } + close(): void { + this._db.close(); + } +} + +class TransactionWrapper { + complete: Promise; + constructor(private _transaction: IDBTransaction) { + this.complete = new Promise((resolve, reject) => { + this._transaction.oncomplete = function () { + resolve(); + }; + this._transaction.onerror = () => { + reject(this._transaction.error); + }; + this._transaction.onabort = () => { + reject(this._transaction.error); + }; + }); + } + objectStore(storeName: string): ObjectStoreWrapper { + return new ObjectStoreWrapper(this._transaction.objectStore(storeName)); + } +} + +class ObjectStoreWrapper { + constructor(private _store: IDBObjectStore) {} + index(name: string): IndexWrapper { + return new IndexWrapper(this._store.index(name)); + } + createIndex( + name: string, + keypath: string, + options: IDBIndexParameters + ): IndexWrapper { + return new IndexWrapper(this._store.createIndex(name, keypath, options)); + } + get(key: string): Promise { + const request = this._store.get(key); + return promisifyRequest(request, 'Error reading from IndexedDB'); + } + put(value: unknown, key?: string): Promise { + const request = this._store.put(value, key); + return promisifyRequest(request, 'Error writing to IndexedDB'); + } + delete(key: string): Promise { + const request = this._store.delete(key); + return promisifyRequest(request, 'Error deleting from IndexedDB'); + } + clear(): Promise { + const request = this._store.clear(); + return promisifyRequest(request, 'Error clearing IndexedDB object store'); + } +} + +class IndexWrapper { + constructor(private _index: IDBIndex) {} + get(key: string): Promise { + const request = this._index.get(key); + return promisifyRequest(request, 'Error reading from IndexedDB'); + } +} + +export function openDB( + dbName: string, + dbVersion: number, + upgradeCallback: ( + db: DBWrapper, + oldVersion: number, + newVersion: number | null, + transaction: TransactionWrapper + ) => void +): Promise { + return new Promise((resolve, reject) => { + try { + const request = indexedDB.open(dbName, dbVersion); + + request.onsuccess = event => { + resolve(new DBWrapper((event.target as IDBOpenDBRequest).result)); + }; + + request.onerror = event => { + reject( + `Error opening indexedDB: ${ + (event.target as IDBRequest).error?.message + }` + ); + }; + + request.onupgradeneeded = event => { + upgradeCallback( + new DBWrapper(request.result), + event.oldVersion, + event.newVersion, + new TransactionWrapper(request.transaction!) + ); + }; + } catch (e) { + reject(`Error opening indexedDB: ${e.message}`); + } + }); +} + +export async function deleteDB(dbName: string): Promise { + return new Promise((resolve, reject) => { + try { + const request = indexedDB.deleteDatabase(dbName); + request.onsuccess = () => { + resolve(); + }; + request.onerror = event => { + reject( + `Error deleting indexedDB database "${dbName}": ${ + (event.target as IDBRequest).error?.message + }` + ); + }; + } catch (e) { + reject(`Error deleting indexedDB database "${dbName}": ${e.message}`); + } + }); +} diff --git a/renovate.json b/renovate.json index 557ce0d9a51..b37b5b6500d 100644 --- a/renovate.json +++ b/renovate.json @@ -17,7 +17,6 @@ "protractor", "long", "rollup-plugin-copy-assets", - "idb", "whatwg-fetch", "typedoc", "@microsoft/tsdoc" diff --git a/yarn.lock b/yarn.lock index b30eb425bea..fdf96b07589 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8993,11 +8993,6 @@ iconv-lite@0.6.3, iconv-lite@^0.6.2: dependencies: safer-buffer ">= 2.1.2 < 3.0.0" -idb@3.0.2: - version "3.0.2" - resolved "https://registry.npmjs.org/idb/-/idb-3.0.2.tgz#c8e9122d5ddd40f13b60ae665e4862f8b13fa384" - integrity sha512-+FLa/0sTXqyux0o6C+i2lOR0VoS60LU/jzUo5xjfY6+7sEEgy4Gz1O7yFBXvjd7N0NyIGWIRg8DcQSLEG+VSPw== - ieee754@^1.1.13, ieee754@^1.1.4: version "1.2.1" resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"