From 6799291de728dfa88cbee09e6fc644ecf410d5ea Mon Sep 17 00:00:00 2001 From: Armando Navarro Date: Tue, 10 Nov 2026 13:53:52 -0800 Subject: [PATCH] feat(app): add initializeBrowserOrServerApp for browser & server apps One call inside provideFirebaseApp that returns the ordinary app in the browser and, on the server, a FirebaseServerApp for the visitor's ID token and App Check token, read from REQUEST_CONTEXT or passed in the settings. With neither token it returns the ordinary app. The server app is released when the render is garbage collected, as the releaseOnDeref setup in the guide does. releaseAfterRender opts into a release at render end or a fixed time after it. A token Firebase cannot parse now renders signed out with one warning instead of an HTTP 500. In dev mode a request with no request context is noted once. The App Check server warning now says which renders send no token. The Node specs can import the built library through a resolve hook. Fixes #3843 --- package.json | 4 +- src/app-check/app-check.module.ts | 7 +- src/app-check/app-check.spec.ts | 35 +- src/app-check/public_api.ts | 2 +- src/app/app.spec.ts | 18 +- src/app/app.ts | 4 +- src/app/browser-or-server-app.jasmine.ts | 434 +++++++++++++++++++++++ src/app/browser-or-server-app.ts | 226 ++++++++++++ src/app/public_api.ts | 5 + tools/angular-fire-resolve-hook.mjs | 52 +++ tools/jasmine-register-angular-fire.mjs | 22 ++ 11 files changed, 801 insertions(+), 8 deletions(-) create mode 100644 src/app/browser-or-server-app.jasmine.ts create mode 100644 src/app/browser-or-server-app.ts create mode 100644 tools/angular-fire-resolve-hook.mjs create mode 100644 tools/jasmine-register-angular-fire.mjs diff --git a/package.json b/package.json index 06adec1d5..4d9564335 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,8 @@ "test:firefox-headless": "npx --yes firebase-tools@latest emulators:exec --project=demo-123 \"ng test --watch=false --browsers=FirefoxHeadless\"", "lint": "ng lint", "lint:fix": "ng lint --fix", - "test:node": "node -r tsconfig-paths/register ./dist/out-tsc/jasmine/tools/jasmine.mjs --input-type=commonjs", - "test:node-esm": "node -r tsconfig-paths/register ./dist/out-tsc/jasmine/tools/jasmine.mjs", + "test:node": "node --import ./tools/jasmine-register-angular-fire.mjs -r tsconfig-paths/register ./dist/out-tsc/jasmine/tools/jasmine.mjs --input-type=commonjs", + "test:node-esm": "node --import ./tools/jasmine-register-angular-fire.mjs -r tsconfig-paths/register ./dist/out-tsc/jasmine/tools/jasmine.mjs", "test:typings": "node ./tools/run-typings-test.js", "test:build": "bash ./test/ng-build/build.sh", "test:all": "npm run test:node-esm && npm run test:chrome-headless && npm run test:typings && npm run test:build", diff --git a/src/app-check/app-check.module.ts b/src/app-check/app-check.module.ts index 14860e3f1..05e1ab03e 100644 --- a/src/app-check/app-check.module.ts +++ b/src/app-check/app-check.module.ts @@ -39,12 +39,17 @@ const isLocalhost = typeof window !== 'undefined' && LOCALHOSTS.includes(window. let warnedServerSkip = false; +/** Resets the server-rendering warning for specs, which share one process. */ +export const ɵresetAppCheckServerWarning = (): void => { + warnedServerSkip = false; +}; + export function appCheckInstanceFactory(fn: (injector: Injector) => FirebaseAppCheck) { return (zone: NgZone, injector: Injector, platformId: unknown) => { if (isPlatformServer(platformId) && !injector.get(APP_CHECK_ON_SERVER)) { if (isDevMode() && !warnedServerSkip) { warnedServerSkip = true; - console.warn("AngularFire skips App Check during server rendering, so server-side Firebase requests carry no App Check token. To run App Check there, provide APP_CHECK_ON_SERVER as true in your server config. Find more at https://github.com/angular/angularfire/blob/main/docs/app-check.md"); + console.warn("AngularFire skips App Check during server rendering, so server renders without a relayed App Check token send none. To relay the browser's App Check token, build the app with initializeBrowserOrServerApp and hand the token to the render as the appCheckToken key of the request context. To run App Check on the server instead, provide APP_CHECK_ON_SERVER as true in your server config. Find more at https://github.com/angular/angularfire/blob/main/docs/app-check.md"); } return undefined; } diff --git a/src/app-check/app-check.spec.ts b/src/app-check/app-check.spec.ts index 861b18dbc..a55800088 100644 --- a/src/app-check/app-check.spec.ts +++ b/src/app-check/app-check.spec.ts @@ -1,7 +1,7 @@ import { PLATFORM_ID, PendingTasks } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { FirebaseApp, deleteApp, getApp, initializeApp, provideFirebaseApp } from '@angular/fire/app'; -import { APP_CHECK_ON_SERVER, AppCheck, CustomProvider, initializeAppCheck, onTokenChanged, provideAppCheck } from '@angular/fire/app-check'; +import { APP_CHECK_ON_SERVER, AppCheck, CustomProvider, initializeAppCheck, onTokenChanged, provideAppCheck, ɵresetAppCheckServerWarning } from '@angular/fire/app-check'; import { Auth, connectAuthEmulator, getAuth, provideAuth } from '@angular/fire/auth'; import { COMMON_CONFIG } from '../test-config'; import { rando } from '../utils'; @@ -54,6 +54,39 @@ describe('AppCheck', () => { beforeEach(() => { appName = rando(); setupCalls = 0; + ɵresetAppCheckServerWarning(); + }); + + const configureServerPlatform = () => { + TestBed.configureTestingModule({ + providers: [ + { provide: PLATFORM_ID, useValue: 'server' }, + provideFirebaseApp(() => initializeApp(COMMON_CONFIG, appName)), + provideTestAppCheck(), + ], + }); + }; + + it('warns once during server rendering, saying what a relayed token changes', () => { + const warn = spyOn(console, 'warn'); + configureServerPlatform(); + TestBed.inject(AppCheck); + expect(warn).toHaveBeenCalledTimes(1); + const message = warn.calls.argsFor(0).join(' '); + expect(message).toContain('server renders without a relayed App Check token send none'); + expect(message).toContain('APP_CHECK_ON_SERVER'); + expect(message).toContain('initializeBrowserOrServerApp'); + }); + + it('warns once per process, not once per render', () => { + const warn = spyOn(console, 'warn'); + configureServerPlatform(); + TestBed.inject(AppCheck); + TestBed.resetTestingModule(); + appName = rando(); + configureServerPlatform(); + TestBed.inject(AppCheck); + expect(warn).toHaveBeenCalledTimes(1); }); it('should skip App Check during server rendering by default', () => { diff --git a/src/app-check/public_api.ts b/src/app-check/public_api.ts index c5704ef08..b24ecfdc3 100644 --- a/src/app-check/public_api.ts +++ b/src/app-check/public_api.ts @@ -1,3 +1,3 @@ export { AppCheck, appCheckInstance$, AppCheckInstances } from './app-check'; -export { provideAppCheck, AppCheckModule, APP_CHECK_ON_SERVER } from './app-check.module'; +export { provideAppCheck, AppCheckModule, APP_CHECK_ON_SERVER, ɵresetAppCheckServerWarning } from './app-check.module'; export * from './firebase'; diff --git a/src/app/app.spec.ts b/src/app/app.spec.ts index d5262aa9c..5c0593816 100644 --- a/src/app/app.spec.ts +++ b/src/app/app.spec.ts @@ -1,6 +1,6 @@ import { PendingTasks } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { FirebaseApp, deleteApp, initializeApp, onLog, provideFirebaseApp } from '@angular/fire/app'; +import { FirebaseApp, deleteApp, getApp, initializeApp, initializeBrowserOrServerApp, onLog, provideFirebaseApp } from '@angular/fire/app'; import { COMMON_CONFIG } from '../test-config'; import { rando } from '../utils'; @@ -55,4 +55,20 @@ describe('FirebaseApp', () => { }); + describe('initializeBrowserOrServerApp', () => { + + // The default app is shared with other specs that build it from COMMON_CONFIG, so no other settings and no deleteApp here. + it('returns the default app in the browser whatever tokens it is given', () => { + TestBed.configureTestingModule({ + providers: [ + provideFirebaseApp(() => initializeBrowserOrServerApp(COMMON_CONFIG, { authIdToken: 'h.e.s', appCheckToken: 'h.e.s' })), + ], + }); + const browserApp = TestBed.inject(FirebaseApp); + expect(browserApp).toBe(getApp()); + expect('settings' in browserApp).toBeFalse(); + }); + + }); + }); diff --git a/src/app/app.ts b/src/app/app.ts index ac4acbe7d..634ade7af 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -12,8 +12,8 @@ import { concatMap, distinct } from 'rxjs/operators'; // back when we constructed our own class. Then in the "new class" we just return the FirebaseApp in the // constructor, this also has the added benefit of Firebase methods taking our DI class without // casting. E.g, -// constructor(private app: FirebaseApp) { } -// ngOnDestroy() { deleteApp(this.app); } +// private readonly app = inject(FirebaseApp); +// readonly firestore = getFirestore(this.app); // export interface FirebaseApp extends IFirebaseApp {} diff --git a/src/app/browser-or-server-app.jasmine.ts b/src/app/browser-or-server-app.jasmine.ts new file mode 100644 index 000000000..ba075d356 --- /dev/null +++ b/src/app/browser-or-server-app.jasmine.ts @@ -0,0 +1,434 @@ +import { EnvironmentInjector, PLATFORM_ID, Provider, REQUEST, REQUEST_CONTEXT, createEnvironmentInjector } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + BrowserOrServerAppSettings, + FirebaseApp, + initializeBrowserOrServerApp, + provideFirebaseApp, + ɵresetBrowserOrServerAppWarnings, +} from '@angular/fire/app'; +import { FirebaseOptions, FirebaseServerApp, FirebaseServerAppSettings, deleteApp, getApp, initializeApp, initializeServerApp } from 'firebase/app'; + +/** One options object for the whole file: a second default app with other options would throw + * app/duplicate-app for the rest of the process. */ +const SPEC_OPTIONS = { apiKey: 'spec-api-key', projectId: 'spec-project', appId: '1:1:web:spec' }; + +/** Encodes a value as the base64url JSON a JWT segment holds. */ +const base64url = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString('base64url'); + +/** + * Builds a token shaped like a JWT, `header.payload.signature`. The SDK reads only the payload, so + * any header and signature do. The payload expires in an hour unless `claims` says otherwise, and + * carries a fresh random `jti` so every token, and so every server app, is distinct from the other + * specs'. + */ +const buildToken = (claims: Record): string => { + const header = base64url({ alg: 'none' }); + const oneHourAhead = Math.floor(Date.now() / 1000) + 3600; + const payload = base64url({ exp: oneHourAhead, jti: Math.random(), ...claims }); + const signature = 'sig'; + return [header, payload, signature].join('.'); +}; + +/** + * Configures a TestBed whose Firebase app comes from `initializeBrowserOrServerApp`, alongside the + * given providers (typically `REQUEST_CONTEXT` and `REQUEST`), and returns the injected app. + */ +const configure = (providers: Provider[], settings?: BrowserOrServerAppSettings, options: FirebaseOptions = SPEC_OPTIONS): FirebaseApp => { + TestBed.configureTestingModule({ + providers: [...providers, provideFirebaseApp(() => initializeBrowserOrServerApp(options, settings))], + }); + return TestBed.inject(FirebaseApp); +}; + +/** A token whose payload decodes to "not json", which JSON.parse rejects, so the SDK's token check throws a SyntaxError. */ +const UNPARSEABLE_PAYLOAD = Buffer.from('not json').toString('base64url'); +const UNPARSEABLE_TOKEN = `h.${UNPARSEABLE_PAYLOAD}.s`; + +/** Everything a `console.warn` spy was called with, joined into one string for `toContain` checks. */ +const warningText = (warn: jasmine.Spy): string => warn.calls.allArgs().flat().join(' '); + +/** Only a FirebaseServerApp has `settings`, which is the public way to tell the two kinds of app apart. */ +const isServerApp = (app: FirebaseApp): app is FirebaseServerApp => 'settings' in app; + +/** Narrows to FirebaseServerApp, failing the spec when the app is the ordinary one. */ +const serverAppOrFail = (app: FirebaseApp): FirebaseServerApp => { + if (!isServerApp(app)) { + throw new Error('expected a server app'); + } + return app; +}; + +const wait = (milliseconds: number): Promise => new Promise(resolve => setTimeout(resolve, milliseconds)); + +/** Reading `settings` on a deleted server app throws `app/server-app-deleted`, the public sign of a release. */ +const isDeleted = (app: FirebaseServerApp): boolean => { + try { return app.settings === undefined } + catch { return true } +}; + +/** The SDK marks a server app deleted only after its providers have torn down, so the wait is bounded, not fixed. */ +const untilDeleted = async (app: FirebaseServerApp): Promise => { + for (let attempt = 0; attempt < 100; attempt++) { + if (isDeleted(app)) { return; } + await wait(10); + } + throw new Error('the server app was not deleted within a second'); +}; + +/** + * Spies on every FinalizationRegistry registration in the process. The SDK registers the object it + * is handed as `releaseOnDeref`, so the spy shows whether, and with what, the function asked for a + * release at garbage collection. + */ +const spyOnRegistry = () => spyOn(FinalizationRegistry.prototype, 'register').and.callThrough(); + +/** + * The objects registered for one app's release, from a process-wide spy that any other + * registration in the process would also reach. The SDK registers each `releaseOnDeref` object + * with the app as the held value. + */ +const registrationsFor = (register: jasmine.Spy, app: FirebaseApp): unknown[] => + register.calls.allArgs().filter(([, heldValue]) => heldValue === app).map(([holder]) => holder); + +describe('initializeBrowserOrServerApp', () => { + beforeEach(() => ɵresetBrowserOrServerAppWarnings()); + afterEach(() => TestBed.resetTestingModule()); + + it('returns the default app on the server with no request context and no settings', () => { + const app = configure([]); + expect(TestBed.inject(PLATFORM_ID)).toBe('server'); + expect(isServerApp(app)).toBeFalse(); + expect(app).toBe(getApp()); + TestBed.resetTestingModule(); + expect(configure([])).toBe(app); + }); + + it('returns a default app the application created itself, without throwing app/duplicate-app', () => { + const existing = initializeApp(SPEC_OPTIONS); + let app: FirebaseApp | undefined; + expect(() => { app = configure([]); }).not.toThrow(); + expect(app).toBe(existing); + }); + + it('builds a server app from the authIdToken in the request context', () => { + const authIdToken = buildToken({}); + const app = serverAppOrFail(configure([{ provide: REQUEST_CONTEXT, useValue: { authIdToken } }])); + expect(app.settings.authIdToken).toBe(authIdToken); + }); + + it('ignores a request context that is not an object or whose token is not a string', () => { + expect(isServerApp(configure([{ provide: REQUEST_CONTEXT, useValue: 'oops' }]))).toBeFalse(); + TestBed.resetTestingModule(); + expect(isServerApp(configure([{ provide: REQUEST_CONTEXT, useValue: { authIdToken: 123 } }]))).toBeFalse(); + }); + + it('prefers an explicit authIdToken over the request context', () => { + const explicit = buildToken({ source: 'settings' }); + const fromRequest = buildToken({ source: 'request' }); + const app = serverAppOrFail(configure([{ provide: REQUEST_CONTEXT, useValue: { authIdToken: fromRequest } }], { authIdToken: explicit })); + expect(app.settings.authIdToken).toBe(explicit); + }); + + it('falls through to the request context when the explicit authIdToken is empty', () => { + const fromRequest = buildToken({}); + const app = serverAppOrFail(configure([{ provide: REQUEST_CONTEXT, useValue: { authIdToken: fromRequest } }], { authIdToken: '' })); + expect(app.settings.authIdToken).toBe(fromRequest); + }); + + it('returns the default app when the request context holds an empty authIdToken', () => { + const app = configure([{ provide: REQUEST_CONTEXT, useValue: { authIdToken: '' } }]); + expect(isServerApp(app)).toBeFalse(); + expect(app).toBe(getApp()); + }); + + it('builds a server app from an explicit appCheckToken with no request context', () => { + const appCheckToken = buildToken({ kind: 'appCheck' }); + const app = serverAppOrFail(configure([], { appCheckToken })); + expect(app.settings.appCheckToken).toBe(appCheckToken); + expect(app.settings.authIdToken).toBeUndefined(); + }); + + it('forwards automaticDataCollectionEnabled to the server app', () => { + const app = serverAppOrFail(configure([], { appCheckToken: buildToken({}), automaticDataCollectionEnabled: false })); + expect(app.settings.automaticDataCollectionEnabled).toBeFalse(); + }); + + describe('releasing the server app', () => { + it('by default hands the SDK its own object to release the app at garbage collection', async () => { + const register = spyOnRegistry(); + const context = { authIdToken: buildToken({}) }; + const settings: BrowserOrServerAppSettings = {}; + const app = serverAppOrFail(configure([{ provide: REQUEST_CONTEXT, useValue: context }], settings)); + const holders = registrationsFor(register, app); + expect(holders.length).toBe(1); + expect(typeof holders[0]).toBe('object'); + expect(holders[0]).not.toBe(context); + expect(holders[0]).not.toBe(settings); + TestBed.resetTestingModule(); + await wait(50); + expect(isDeleted(app)).toBeFalse(); + }); + + it('releases the app when the render ends with releaseAfterRender 0', async () => { + const register = spyOnRegistry(); + const app = serverAppOrFail(configure([], { authIdToken: buildToken({}), releaseAfterRender: 0 })); + expect(register).not.toHaveBeenCalled(); + TestBed.resetTestingModule(); + await untilDeleted(app); + }); + + it('releases the app that many milliseconds after the render with a positive releaseAfterRender', async () => { + const app = serverAppOrFail(configure([], { authIdToken: buildToken({}), releaseAfterRender: 200 })); + TestBed.resetTestingModule(); + await wait(50); + expect(isDeleted(app)).toBeFalse(); + await untilDeleted(app); + }); + + it('caps a releaseAfterRender too long for a timer instead of releasing at once', async () => { + const emitWarning = spyOn(process, 'emitWarning'); + const app = serverAppOrFail(configure([], { authIdToken: buildToken({}), releaseAfterRender: Infinity })); + TestBed.resetTestingModule(); + await wait(50); + expect(isDeleted(app)).toBeFalse(); + expect(emitWarning).not.toHaveBeenCalled(); + await deleteApp(app); + }); + + it('releases at render end when the runtime has no FinalizationRegistry', async () => { + const registry = Reflect.get(globalThis, 'FinalizationRegistry'); + Reflect.deleteProperty(globalThis, 'FinalizationRegistry'); + try { + const app = serverAppOrFail(configure([], { authIdToken: buildToken({}) })); + TestBed.resetTestingModule(); + await untilDeleted(app); + } finally { + Reflect.set(globalThis, 'FinalizationRegistry', registry); + } + }); + + it('shares one app between two renders with the same token and releases it after the last', async () => { + const authIdToken = buildToken({}); + const providers = [provideFirebaseApp(() => initializeBrowserOrServerApp(SPEC_OPTIONS, { authIdToken, releaseAfterRender: 0 }))]; + const parent = TestBed.inject(EnvironmentInjector); + const first = createEnvironmentInjector(providers, parent); + const second = createEnvironmentInjector(providers, parent); + const app = serverAppOrFail(first.get(FirebaseApp)); + expect(second.get(FirebaseApp)).toBe(app); + first.destroy(); + await wait(50); + expect(isDeleted(app)).toBeFalse(); + second.destroy(); + await untilDeleted(app); + }); + + it('registers one release object per render in the default mode', () => { + const register = spyOnRegistry(); + const authIdToken = buildToken({}); + const providers = [provideFirebaseApp(() => initializeBrowserOrServerApp(SPEC_OPTIONS, { authIdToken }))]; + const parent = TestBed.inject(EnvironmentInjector); + const first = createEnvironmentInjector(providers, parent); + const second = createEnvironmentInjector(providers, parent); + const app = first.get(FirebaseApp); + expect(second.get(FirebaseApp)).toBe(app); + const holders = registrationsFor(register, app); + expect(holders.length).toBe(2); + expect(holders[0]).not.toBe(holders[1]); + first.destroy(); + second.destroy(); + }); + + it('never hands the SDK a releaseOnDeref the type could not see', () => { + const register = spyOnRegistry(); + const callerObject = {}; + const loose = JSON.parse(JSON.stringify({ authIdToken: buildToken({}) })); + loose.releaseOnDeref = callerObject; + const app = configure([], loose); + expect(isServerApp(app)).toBeTrue(); + const holders = registrationsFor(register, app); + expect(holders.length).toBe(1); + expect(holders[0]).not.toBe(callerObject); + }); + + it('never hands the SDK a releaseOnDeref in the timed mode either', () => { + const register = spyOnRegistry(); + const loose = JSON.parse(JSON.stringify({ authIdToken: buildToken({}), releaseAfterRender: 0 })); + loose.releaseOnDeref = {}; + expect(isServerApp(configure([], loose))).toBeTrue(); + expect(register).not.toHaveBeenCalled(); + }); + + it('rejects releaseOnDeref in the type', () => { + const typed: FirebaseServerAppSettings = { authIdToken: 'h.e.s', releaseOnDeref: {} }; + // @ts-expect-error releaseOnDeref is not part of BrowserOrServerAppSettings + const literal = () => initializeBrowserOrServerApp(SPEC_OPTIONS, { authIdToken: 'h.e.s', releaseOnDeref: {} }); + // @ts-expect-error a FirebaseServerAppSettings variable is not accepted either + const variable = () => initializeBrowserOrServerApp(SPEC_OPTIONS, typed); + expect(typeof literal).toBe('function'); + expect(typeof variable).toBe('function'); + }); + }); + + describe('tokens the SDK rejects', () => { + it('renders signed out with one fixed warning when a token cannot be parsed', () => { + const warn = spyOn(console, 'warn'); + const app = configure([{ provide: REQUEST_CONTEXT, useValue: { authIdToken: UNPARSEABLE_TOKEN } }]); + expect(isServerApp(app)).toBeFalse(); + expect(app).toBe(getApp()); + expect(warn).toHaveBeenCalledTimes(1); + expect(warningText(warn)).toContain('could not be parsed'); + expect(warningText(warn)).not.toContain(UNPARSEABLE_PAYLOAD); + expect(warningText(warn)).not.toContain('not json'); + TestBed.resetTestingModule(); + expect(configure([{ provide: REQUEST_CONTEXT, useValue: { authIdToken: UNPARSEABLE_TOKEN } }])).toBe(getApp()); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it('leaves a junk token to the SDK and still releases the app it builds', async () => { + spyOn(console, 'error'); + const warn = spyOn(console, 'warn'); + const app = serverAppOrFail(configure([], { authIdToken: 'garbagegarbage', releaseAfterRender: 0 })); + expect(warn).not.toHaveBeenCalled(); + TestBed.resetTestingModule(); + await untilDeleted(app); + }); + + it('renders signed out when a valid ID token comes with an unparseable App Check token', () => { + const warn = spyOn(console, 'warn'); + const app = configure([], { authIdToken: buildToken({}), appCheckToken: UNPARSEABLE_TOKEN }); + expect(isServerApp(app)).toBeFalse(); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it('lets a FirebaseError from initializeServerApp propagate', () => { + const warn = spyOn(console, 'warn'); + Reflect.set(globalThis, 'window', {}); + try { + expect(() => configure([], { authIdToken: buildToken({}) })) + .toThrowMatching(error => Reflect.get(error, 'code') === 'app/invalid-server-app-environment'); + } finally { + Reflect.deleteProperty(globalThis, 'window'); + } + expect(warn).not.toHaveBeenCalled(); + }); + + it('lets a native error that is not a token error propagate', () => { + const warn = spyOn(console, 'warn'); + const throwingOptions: FirebaseOptions = { + get apiKey(): string { + throw new RangeError('spec'); + }, + projectId: SPEC_OPTIONS.projectId, + appId: SPEC_OPTIONS.appId, + }; + expect(() => configure([], { authIdToken: buildToken({}) }, throwingOptions)).toThrowError(RangeError); + expect(warn).not.toHaveBeenCalled(); + }); + + it('builds a server app for an expired token and releases it', async () => { + spyOn(console, 'error'); + const warn = spyOn(console, 'warn'); + const app = serverAppOrFail(configure([], { authIdToken: buildToken({ exp: Math.floor(Date.now() / 1000) - 3600 }), releaseAfterRender: 0 })); + expect(warn).not.toHaveBeenCalled(); + TestBed.resetTestingModule(); + await untilDeleted(app); + }); + + it('warns on the first unparseable token and then every hundredth', () => { + const warn = spyOn(console, 'warn'); + for (let index = 0; index < 250; index++) { + configure([{ provide: REQUEST_CONTEXT, useValue: { authIdToken: UNPARSEABLE_TOKEN } }]); + TestBed.resetTestingModule(); + } + expect(warn).toHaveBeenCalledTimes(3); + expect(warn.calls.argsFor(0).join(' ')).toContain('could not be parsed'); + expect(warn.calls.argsFor(1).join(' ')).toContain('100'); + expect(warn.calls.argsFor(2).join(' ')).toContain('200'); + expect(warningText(warn)).not.toContain(UNPARSEABLE_PAYLOAD); + }); + }); + + describe('the note for a request without a context', () => { + const request = (): Provider => ({ provide: REQUEST, useValue: new Request('http://localhost/account') }); + + it('notes once per process when a request arrives with an undefined context', () => { + const warn = spyOn(console, 'warn'); + configure([request(), { provide: REQUEST_CONTEXT, useValue: undefined }]); + expect(warn).toHaveBeenCalledTimes(1); + expect(warningText(warn)).toContain('handle()'); + TestBed.resetTestingModule(); + configure([request(), { provide: REQUEST_CONTEXT, useValue: undefined }]); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it('notes when the context is null', () => { + const warn = spyOn(console, 'warn'); + configure([request(), { provide: REQUEST_CONTEXT, useValue: null }]); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it('says nothing without a request, as in prerendering', () => { + const warn = spyOn(console, 'warn'); + configure([]); + expect(warn).not.toHaveBeenCalled(); + }); + + it('says nothing when the tokens are passed in the settings, as with an Authorization header', () => { + const warn = spyOn(console, 'warn'); + configure([request(), { provide: REQUEST_CONTEXT, useValue: undefined }], { authIdToken: undefined }); + expect(warn).not.toHaveBeenCalled(); + }); + }); + + describe('a server app whose tokens differ from the requested ones', () => { + /** A server app built for `token` whose settings were then changed to carry another token. */ + const serverAppWithChangedToken = (): { token: string; existing: FirebaseServerApp } => { + const token = buildToken({}); + const existing = initializeServerApp(SPEC_OPTIONS, { authIdToken: token }); + existing.settings.authIdToken = buildToken({}); + return { token, existing }; + }; + + it('returns the ordinary app and gives its reference back at once in the timed mode', async () => { + const { token, existing } = serverAppWithChangedToken(); + const warn = spyOn(console, 'warn'); + const app = configure([], { authIdToken: token, releaseAfterRender: 0 }); + expect(isServerApp(app)).toBeFalse(); + expect(warn).toHaveBeenCalledTimes(1); + expect(warningText(warn)).toContain('could not build a server app'); + expect(warningText(warn)).not.toContain(token.split('.')[1]); + await deleteApp(existing); + await untilDeleted(existing); + }); + + it('returns the ordinary app and leaves its reference to the collector in the default mode', async () => { + const { token, existing } = serverAppWithChangedToken(); + const register = spyOnRegistry(); + const warn = spyOn(console, 'warn'); + const app = configure([], { authIdToken: token }); + expect(isServerApp(app)).toBeFalse(); + expect(warn).toHaveBeenCalledTimes(1); + expect(registrationsFor(register, existing).length).toBe(1); + await deleteApp(existing); + await wait(100); + expect(isDeleted(existing)).toBeFalse(); + }); + + it('warns on the first occurrence and then every hundredth', async () => { + const { token, existing } = serverAppWithChangedToken(); + const warn = spyOn(console, 'warn'); + for (let index = 0; index < 100; index++) { + configure([], { authIdToken: token, releaseAfterRender: 0 }); + TestBed.resetTestingModule(); + } + expect(warn).toHaveBeenCalledTimes(2); + expect(warn.calls.argsFor(1).join(' ')).toContain('100'); + await deleteApp(existing); + }); + }); + + it('throws outside an injection context', () => { + expect(() => initializeBrowserOrServerApp(SPEC_OPTIONS)).toThrowError(/initializeBrowserOrServerApp/); + }); +}); diff --git a/src/app/browser-or-server-app.ts b/src/app/browser-or-server-app.ts new file mode 100644 index 000000000..7d01320bc --- /dev/null +++ b/src/app/browser-or-server-app.ts @@ -0,0 +1,226 @@ +import { isPlatformServer } from '@angular/common'; +import { DestroyRef, PLATFORM_ID, REQUEST, REQUEST_CONTEXT, assertInInjectionContext, inject, isDevMode } from '@angular/core'; +import { VERSION } from '@angular/fire'; +import { + FirebaseApp, + FirebaseAppSettings, + FirebaseOptions, + FirebaseServerApp, + FirebaseServerAppSettings, + deleteApp, + initializeApp, + initializeServerApp, + registerVersion, +} from 'firebase/app'; + +export type BrowserOrServerAppSettings = Omit & { + /** + * Passing this is a compile error, which is why it is typed `never`. The Firebase SDK's + * `releaseOnDeref` releases the server app when the object it is given is garbage collected. + * `initializeBrowserOrServerApp` manages that release itself, and a server app released twice + * can be deleted while another render is still using it. Use `releaseAfterRender` to change + * when the release happens. + */ + releaseOnDeref?: never; + /** + * How long the server app built for a render outlives that render, in milliseconds. + * + * Each signed-in server render gets a FirebaseServerApp, and building one costs a Firestore + * connection and an account lookup. The app is cached by the Firebase SDK, so a later render for + * the same visitor can reuse it as long as it is still alive. This setting decides when it dies. + * + * - Left out (the default): the app lives until Node garbage-collects the finished render, the + * same as `releaseOnDeref` in a hand-written setup. Repeat renders reuse it, and memory is + * bounded by the garbage collector. + * - `0`: the app is released as soon as the render ends. Memory is bounded by the renders in + * flight, and every repeat render pays the build cost again. + * - A positive number: the app is kept for that long after the render ends, so a reload or a + * second tab inside that window reuses it. Values above 2147483647 (about 24.8 days) are + * capped there. + * + * Example: `initializeBrowserOrServerApp(firebaseConfig, { releaseAfterRender: 60_000 })`. + */ + releaseAfterRender?: number; +}; + +const RELEASE_FAILED_MESSAGE = 'AngularFire: releasing the server app after the render failed.'; +const UNPARSEABLE_TOKEN_MESSAGE = 'AngularFire: the ID token or App Check token for this render could not be parsed, so the page renders signed out. Find more at https://github.com/angular/angularfire/blob/main/docs/auth.md'; +const MISSING_REQUEST_CONTEXT_MESSAGE = 'AngularFire: this server render has a request but no request context, so no ID token or App Check token can be read. Pass the context as the second argument of handle() in server.ts. Find more at https://github.com/angular/angularfire/blob/main/docs/auth.md'; +const TOKEN_MISMATCH_MESSAGE = 'AngularFire: could not build a server app for the tokens of this render, so the page renders signed out.'; + +// setTimeout fires at once for any delay above this. +const MAX_TIMER_DELAY = 2147483647; + +const warningCounts = new Map(); +let notedMissingRequestContext = false; + +/** Resets this file's once-per-process warnings for specs, which share one process. */ +export const ɵresetBrowserOrServerAppWarnings = (): void => { + warningCounts.clear(); + notedMissingRequestContext = false; +}; + +/** The two tokens a render can carry, each `undefined` when absent. */ +interface RenderTokens { + authIdToken: string | undefined; + appCheckToken: string | undefined; +} + +// Logged in full once per process, then one line per hundred, so a token that keeps failing cannot add a line of ours per request. +const warnRateLimited = (message: string, repeatLine: (count: number) => string): void => { + const count = (warningCounts.get(message) ?? 0) + 1; + warningCounts.set(message, count); + if (count === 1) { + console.warn(message); + } else if (count % 100 === 0) { + console.warn(repeatLine(count)); + } +}; + +/** + * A request with no context, and no token passed in, is the shape of a server.ts that forgot + * handle()'s second argument. Prerendering has no request, and the context is undefined, not + * null, when the argument is missing. + */ +const noteMissingRequestContext = (request: Request | null, requestContext: unknown, settings: BrowserOrServerAppSettings): void => { + const tokensPassedIn = 'authIdToken' in settings || 'appCheckToken' in settings; + if (!isDevMode() || request === null || (requestContext !== null && requestContext !== undefined) || tokensPassedIn || notedMissingRequestContext) { + return; + } + notedMissingRequestContext = true; + console.warn(MISSING_REQUEST_CONTEXT_MESSAGE); +}; + +// Compared by name, not instanceof: a second copy of @firebase/util would make instanceof miss, and the SDK's own errors are FirebaseError. +const isTokenParseError = (error: unknown): boolean => { + const name = typeof error === 'object' && error !== null ? Reflect.get(error, 'name') : undefined; + return name === 'SyntaxError' || name === 'TypeError'; +}; + +const readStringProperty = (value: unknown, key: string): string | undefined => { + if (typeof value !== 'object' || value === null || !(key in value)) { + return undefined; + } + const property = Reflect.get(value, key); + return typeof property === 'string' ? property : undefined; +}; + +const nonEmptyString = (value: unknown): string | undefined => + typeof value === 'string' && value.length > 0 ? value : undefined; + +/** A non-empty token in the settings wins, then a non-empty one in the request context. */ +const readTokens = (settings: BrowserOrServerAppSettings, requestContext: unknown): RenderTokens => ({ + authIdToken: nonEmptyString(settings.authIdToken) ?? nonEmptyString(readStringProperty(requestContext, 'authIdToken')), + appCheckToken: nonEmptyString(settings.appCheckToken) ?? nonEmptyString(readStringProperty(requestContext, 'appCheckToken')), +}); + +const release = (serverApp: FirebaseServerApp): void => { + deleteApp(serverApp).catch((error: unknown) => console.warn(RELEASE_FAILED_MESSAGE, error)); +}; + +const hasUnref = (timer: unknown): timer is { unref: () => void } => + typeof timer === 'object' && timer !== null && typeof Reflect.get(timer, 'unref') === 'function'; + +const scheduleRelease = (serverApp: FirebaseServerApp, delay: number): void => { + if (delay <= 0) { + release(serverApp); + return; + } + // A pending release must not keep a server process from exiting. + const timer: unknown = setTimeout(() => release(serverApp), Math.min(delay, MAX_TIMER_DELAY)); + if (hasUnref(timer)) { + timer.unref(); + } +}; + +/** + * Builds the server app for a render's tokens and arranges its release, or returns `undefined` + * when the ordinary app should be used instead: a token the SDK cannot parse, or an app whose + * tokens are not the ones asked for. + */ +const buildServerApp = ( + options: FirebaseOptions, + settings: BrowserOrServerAppSettings, + appSettings: FirebaseAppSettings, + tokens: RenderTokens, + destroyRef: DestroyRef, +): FirebaseServerApp | undefined => { + const { releaseOnDeref: _releaseOnDeref, releaseAfterRender, automaticDataCollectionEnabled: _automaticDataCollectionEnabled, ...serverSettings } = settings; + const timedRelease = releaseAfterRender !== undefined || typeof FinalizationRegistry === 'undefined'; + + // Kept reachable only through the injector's destroy hooks, which Angular empties at destroy, so the SDK can collect it right after the render. + const renderHolder = {}; + if (!timedRelease) { + destroyRef.onDestroy(() => renderHolder); + } + + let serverApp: FirebaseServerApp; + try { + serverApp = initializeServerApp(options, { + ...serverSettings, + ...appSettings, + ...tokens, + ...(timedRelease ? {} : { releaseOnDeref: renderHolder }), + }); + } catch (error) { + if (!isTokenParseError(error)) { + throw error; + } + warnRateLimited(UNPARSEABLE_TOKEN_MESSAGE, count => `AngularFire: ${count} renders in this process carried a token that could not be parsed.`); + return undefined; + } + + if (serverApp.settings.authIdToken !== tokens.authIdToken || serverApp.settings.appCheckToken !== tokens.appCheckToken) { + if (timedRelease) { + release(serverApp); + } + warnRateLimited(TOKEN_MISMATCH_MESSAGE, count => `AngularFire: ${count} renders in this process could not build a server app for their tokens.`); + return undefined; + } + + if (timedRelease) { + // Inside an environment injector DestroyRef is the injector itself, so this runs when the render's injector is destroyed. + destroyRef.onDestroy(() => scheduleRelease(serverApp, releaseAfterRender ?? 0)); + } + return serverApp; +}; + +/** + * Call inside provideFirebaseApp. In the browser it returns the ordinary Firebase app. On the + * server it returns a FirebaseServerApp for the visitor's ID token and App Check token when + * either is present, read from the request context handed to handle() in server.ts or passed in + * the settings, and the ordinary app when neither is. + * + * It always builds the default app. Keep exactly one provideFirebaseApp, and pass + * inject(FirebaseApp) to every product provider, since the server app is not the default app. + * An application with several Firebase projects writes its own provideFirebaseApp callback + * instead. FirebaseApps and firebaseApp$ list ordinary apps only, never a server app. + */ +export function initializeBrowserOrServerApp( + options: FirebaseOptions, + settings: BrowserOrServerAppSettings = {}, +): FirebaseApp { + assertInInjectionContext(initializeBrowserOrServerApp); + const platformId = inject(PLATFORM_ID); + const requestContext = inject(REQUEST_CONTEXT, { optional: true }); + const request = inject(REQUEST, { optional: true }); + const destroyRef = inject(DestroyRef); + registerVersion('angularfire', VERSION.full, 'browser-or-server-app'); + + // The key must be absent, not undefined: the SDK deep-compares settings with an existing default app. + const appSettings: FirebaseAppSettings = + settings.automaticDataCollectionEnabled === undefined ? {} : { automaticDataCollectionEnabled: settings.automaticDataCollectionEnabled }; + + if (!isPlatformServer(platformId)) { + return initializeApp(options, appSettings); + } + + noteMissingRequestContext(request, requestContext, settings); + + const tokens = readTokens(settings, requestContext); + if (tokens.authIdToken === undefined && tokens.appCheckToken === undefined) { + return initializeApp(options, appSettings); + } + + return buildServerApp(options, settings, appSettings, tokens, destroyRef) ?? initializeApp(options, appSettings); +} diff --git a/src/app/public_api.ts b/src/app/public_api.ts index e33036709..603b75cc8 100644 --- a/src/app/public_api.ts +++ b/src/app/public_api.ts @@ -1,3 +1,8 @@ export { FirebaseApp, FirebaseApps, firebaseApp$ } from './app'; export { provideFirebaseApp, FirebaseAppModule } from './app.module'; +export { + BrowserOrServerAppSettings, + initializeBrowserOrServerApp, + ɵresetBrowserOrServerAppWarnings, +} from './browser-or-server-app'; export * from './firebase'; diff --git a/tools/angular-fire-resolve-hook.mjs b/tools/angular-fire-resolve-hook.mjs new file mode 100644 index 000000000..cfdcef0f9 --- /dev/null +++ b/tools/angular-fire-resolve-hook.mjs @@ -0,0 +1,52 @@ +/** + * @fileoverview A Node module resolve hook for the jasmine run. + * + * The Node specs import `@angular/fire`, but nothing installs that package into `node_modules`: + * the library under test is the fresh build in `dist/packages-dist`. Without help, Node's module + * loader fails every such import with ERR_MODULE_NOT_FOUND. This hook steps in front of the + * loader and points `@angular/fire` and `@angular/fire/` at the files the built package's + * own `exports` map lists. Every other import passes through untouched, including the library's + * own `firebase/app` import, so the specs and the library share one copy of the Firebase SDK. + * + * Loaded by `jasmine-register-angular-fire.mjs`, which is what the `test:node` scripts preload. + */ + +import { readFileSync } from 'node:fs'; + +const packageName = '@angular/fire'; + +let packageRoot; +let exportsMap; + +/** + * Reads the built package's `exports` map once. Called by the registering file, either directly + * (`module.registerHooks`) or by Node with the `data` passed to `module.register`. + * @param {{ packageRoot: string }} data `packageRoot` is the `file:` URL of `dist/packages-dist/`. + */ +export function initialize(data) { + packageRoot = data.packageRoot; + exportsMap = JSON.parse(readFileSync(new URL('package.json', packageRoot), 'utf8')).exports; +} + +/** + * Resolves `@angular/fire` and `@angular/fire/` to the built files and leaves every other + * specifier to Node. Throws when the built package has no such entry, so a typo in a spec's import + * fails loudly instead of resolving to nothing. + * @param {string} specifier The import text, for example `@angular/fire/app`. + * @param {object} context Node's resolve context, passed through to `nextResolve`. + * @param {Function} nextResolve Node's own resolver, for every other import. + * @returns {{ url: string, shortCircuit: boolean }} The resolved file. + */ +export function resolve(specifier, context, nextResolve) { + if (specifier !== packageName && !specifier.startsWith(`${packageName}/`)) { + return nextResolve(specifier, context); + } + const subpath = specifier === packageName ? '.' : `./${specifier.slice(packageName.length + 1)}`; + const mapped = exportsMap[subpath]; + const entry = typeof mapped === 'string' ? mapped : mapped?.default; + if (typeof entry !== 'string') { + throw new Error(`The built ${packageName} has no "${subpath}" entry in its exports map, so "${specifier}" cannot be resolved for the Node specs.`); + } + // No format: Node reads it from the resolved file's extension, .mjs for the library and .json for its package.json. + return { url: new URL(entry, packageRoot).href, shortCircuit: true }; +} diff --git a/tools/jasmine-register-angular-fire.mjs b/tools/jasmine-register-angular-fire.mjs new file mode 100644 index 000000000..047ed1013 --- /dev/null +++ b/tools/jasmine-register-angular-fire.mjs @@ -0,0 +1,22 @@ +/** + * @fileoverview Installs the `@angular/fire` resolve hook before any spec is loaded. + * + * The `test:node` and `test:node-esm` scripts preload this file with Node's `--import` flag, so it + * runs before `tools/jasmine.ts` and before jasmine imports the first spec. It does nothing but + * register `angular-fire-resolve-hook.mjs`. + */ + +import * as nodeModule from 'node:module'; +import { resolve as resolvePath } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { initialize, resolve } from './angular-fire-resolve-hook.mjs'; + +const data = { packageRoot: pathToFileURL(resolvePath('dist/packages-dist') + '/').href }; + +// registerHooks runs the hook on the main thread and replaces register, which Node 26 deprecates. Node 20 has only register. +if (typeof nodeModule.registerHooks === 'function') { + initialize(data); + nodeModule.registerHooks({ resolve }); +} else { + nodeModule.register('./angular-fire-resolve-hook.mjs', import.meta.url, { data }); +}