diff --git a/packages/live/src/messaging.ts b/packages/live/src/messaging.ts new file mode 100644 index 000000000..19ed7e043 --- /dev/null +++ b/packages/live/src/messaging.ts @@ -0,0 +1,288 @@ +/** + * What the live-messaging rows know about `firebase/messaging` and + * `firebase/messaging/sw`: where a call's app and project sit on the SDK's + * Messaging instance, which Installations and FCM registration requests a + * `getToken` or `deleteToken` made, how production's answer is read from + * them and from the SDK's error, and which calls made no request at all. + * + * The observed calls are `getToken` and `deleteToken` on the page, and the + * deliveries of `onMessage` (page) and `onBackgroundMessage` (service + * worker), one record per message the SDK hands the app. The subscribe call + * itself has no production answer. `getMessaging`, `isSupported` and + * `experimentalSetDeliveryMetricsExportedToBigQueryEnabled` are the SDK's + * own values and make no record: `isSupported` checks browser APIs and sends + * no request. + * + * The requests behind a call come from Resource Timing, which the browser + * keeps for the SDK's own `fetch` in the page and the service worker without + * touching the request. A `PerformanceObserver` on `resource` entries starts + * at the realm's first `getToken` or `deleteToken`, and each call takes the + * entries for `firebaseinstallations.googleapis.com` and + * `fcmregistrations.googleapis.com` that started between the call and its + * settlement. Each becomes an API, an action and production's HTTP status: + * the URL's project, installation id (FID) and token are dropped as the entry + * is read, and the status is left out when the browser reports 0. A call + * keeps its last four requests: another service's concurrent Installations + * call can land in the window. Two Messaging calls running at once in one + * realm each take the other's requests too. + * + * A call that settles with no such entry made no request: the stored token + * was valid, there was none to delete, or the SDK failed locally + * (`messaging/permission-blocked`, `unsupported-browser`). It makes no + * record. An error that carries production's answer (an Installations + * `customData.serverCode`, or a `messaging/token-*` failure) is recorded + * even when the browser kept no entry for it. Where the realm has no + * `PerformanceObserver`, every settled call is recorded, without requests. + * + * A record never carries the token, the VAPID key, the push subscription's + * endpoint or keys, the FID, or any payload field. Reading never calls the + * app's code and never throws. This file imports no Firebase SDK. + */ +import { aiCode } from './ai.js'; +import { UNOBSERVED, type CallContext, type LiveRequest, type ObservedSite, type RejectionReading, type ResultReading } from './observe.js'; + +type Fields = Record; + +/** The one-shot `firebase/messaging` calls the row observes. */ +export const MESSAGING_CALLS = ['getToken', 'deleteToken'] as const; + +/** The most requests a host takes on one call. */ +export const MAX_REQUESTS = 4; + +/** The most recent entries a watcher keeps between calls. */ +const KEPT_ENTRIES = 32; + +const API_BY_HOST: ReadonlyMap = new Map([ + ['firebaseinstallations.googleapis.com', 'installations'], + ['fcmregistrations.googleapis.com', 'registrations'], +]); + +/** What a request's path says it did, read before the path is dropped. A registration path names a token or a FID, which is not kept. */ +type PathShape = 'installations' | 'authToken' | 'registrations' | 'registration'; + +const PATH_SHAPES: readonly (readonly [RegExp, PathShape])[] = [ + [/^\/v1\/projects\/[^/]+\/installations$/, 'installations'], + [/^\/v1\/projects\/[^/]+\/installations\/[^/]+\/authTokens:generate$/, 'authToken'], + [/^\/v1\/projects\/[^/]+\/registrations$/, 'registrations'], + [/^\/v1\/projects\/[^/]+\/registrations\/[^/]+$/, 'registration'], +]; + +/** One watched entry: its shape, when it started, and production's status, if the browser reported one. */ +interface SeenRequest { + readonly shape: PathShape; + readonly startTime: number; + readonly status?: number; +} + +interface Watcher { + /** The entries kept so far, with any the browser has queued but not yet delivered. */ + take(): readonly SeenRequest[]; +} + +type PerformanceObserverClass = new (callback: (list: { getEntries(): unknown[] }) => void) => { + observe(options: { type: string; buffered?: boolean }): void; + takeRecords(): unknown[]; +}; + +/** One watcher for each `PerformanceObserver` class the realm has had. */ +const WATCHERS = new WeakMap(); + +function isObject(value: unknown): value is Fields { + return (typeof value === 'object' || typeof value === 'function') && value !== null; +} + +function valueAt(value: unknown, keys: readonly string[]): unknown { + let current = value; + for (const key of keys) { + if (!isObject(current)) return undefined; + current = current[key]; + } + return current; +} + +function stringAt(value: unknown, keys: readonly string[]): string | undefined { + const found = valueAt(value, keys); + return typeof found === 'string' && found !== '' ? found : undefined; +} + +function isHttpStatus(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value >= 100 && value <= 599; +} + +/** An entry as the watcher keeps it, or undefined for one that is not an Installations or registrations request. */ +function seenOf(entry: unknown): SeenRequest | undefined { + const name = stringAt(entry, ['name']); + const startTime = valueAt(entry, ['startTime']); + if (name === undefined || typeof startTime !== 'number') return undefined; + let url: URL; + try { + url = new URL(name); + } catch { + return undefined; + } + if (!API_BY_HOST.has(url.hostname)) return undefined; + const shape = PATH_SHAPES.find(([pattern]) => pattern.test(url.pathname))?.[1]; + if (shape === undefined) return undefined; + const status = valueAt(entry, ['responseStatus']); + return { shape, startTime, ...(isHttpStatus(status) ? { status } : {}) }; +} + +function keep(seen: SeenRequest[], entries: readonly unknown[]): void { + for (const entry of entries) { + const request = seenOf(entry); + if (request !== undefined) seen.push(request); + } + if (seen.length > KEPT_ENTRIES) seen.splice(0, seen.length - KEPT_ENTRIES); +} + +function startWatcher(Observer: PerformanceObserverClass): Watcher { + const seen: SeenRequest[] = []; + const observer = new Observer((list) => keep(seen, list.getEntries())); + observer.observe({ type: 'resource', buffered: true }); + return { + take() { + keep(seen, observer.takeRecords()); + return seen; + }, + }; +} + +/** The realm's watcher, started on first use, or null where the realm has no `PerformanceObserver`. */ +function watcher(): Watcher | null { + try { + const Observer = (globalThis as { PerformanceObserver?: unknown }).PerformanceObserver; + if (typeof Observer !== 'function') return null; + const existing = WATCHERS.get(Observer); + if (existing !== undefined) return existing; + const started = startWatcher(Observer as PerformanceObserverClass); + WATCHERS.set(Observer, started); + return started; + } catch { + // A realm whose observer cannot start records every settled call, without requests. + return null; + } +} + +function timelineNow(): number | undefined { + try { + return typeof performance === 'object' && typeof performance.now === 'function' ? performance.now() : undefined; + } catch { + return undefined; + } +} + +/** + * The record context of one Messaging call or listener, from its Messaging + * instance (`args[0]`): the app, whose Auth names the account, and its + * project. A `getToken` or `deleteToken` also starts the realm's watcher + * and notes when it started on the performance timeline. + */ +export function messagingContext(name: string, args: readonly unknown[]): CallContext { + const app = valueAt(args[0], ['app']); + const projectId = stringAt(app, ['options', 'projectId']); + const context: CallContext = { ...(isObject(app) ? { subject: app } : {}), ...(projectId === undefined ? {} : { projectId }) }; + const isCall = (MESSAGING_CALLS as readonly string[]).includes(name); + if (!isCall || watcher() === null) return context; + const timelineStart = timelineNow(); + return timelineStart === undefined ? context : { ...context, timelineStart }; +} + +/** + * The action of each request, in order. A `/registrations/` entry is a + * `delete` in `deleteToken`; in `getToken` it is an `update`, or a `delete` + * when the next registrations request creates one, as the SDK deletes a + * stale token before it subscribes again. + */ +function requestsOf(name: string, seen: readonly SeenRequest[]): LiveRequest[] { + return seen.map((request, index) => { + const status = request.status === undefined ? {} : { status: request.status }; + if (request.shape === 'installations') return { api: 'installations', action: 'create', ...status }; + if (request.shape === 'authToken') return { api: 'installations', action: 'generateAuthToken', ...status }; + if (request.shape === 'registrations') return { api: 'registrations', action: 'create', ...status }; + const nextRegistration = seen.slice(index + 1).find((later) => later.shape === 'registrations' || later.shape === 'registration'); + const isReplaced = name === 'deleteToken' || nextRegistration?.shape === 'registrations'; + return { api: 'registrations', action: isReplaced ? 'delete' : 'update', ...status }; + }); +} + +/** Lets the browser report the entry of the call's last request, which can land just after the SDK settles. */ +function afterSettling(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +/** The requests the call at `site` made, at most four, or undefined where the realm has no watcher. */ +async function callRequests(site: ObservedSite): Promise { + const start = site.timelineStart; + const realm = watcher(); + if (start === undefined || realm === null) return undefined; + await afterSettling(); + const seen = realm.take().filter((request) => request.startTime >= start); + return requestsOf(site.name, seen).slice(-MAX_REQUESTS); +} + +/** A record's requests, or nothing for none. */ +function requestsField(requests: readonly LiveRequest[] | undefined): Pick { + return requests === undefined || requests.length === 0 ? {} : { requests }; +} + +/** What a resolved `getToken` or `deleteToken` adds: the requests it made, or no record when it made none. */ +export async function messagingResult(site: ObservedSite): Promise { + const requests = await callRequests(site); + const isLocal = requests !== undefined && requests.length === 0; + return isLocal ? UNOBSERVED : requestsField(requests); +} + +/** The codes `readObservedCall` takes. */ +const CODE_PATTERN = /^[a-z0-9_/-]{1,100}$/; + +/** Production's canonical name for an HTTP status, by AI Logic's mapping (`aiCode`). */ +function statusCode(status: number): string { + return aiCode({ customErrorData: { status } }); +} + +/** The status production sent, lowercased, as an Installations error keeps it in `customData.serverStatus`. */ +function serverStatusOf(error: unknown): string | undefined { + const status = stringAt(error, ['customData', 'serverStatus'])?.toLowerCase(); + return status !== undefined && CODE_PATTERN.test(status) ? status : undefined; +} + +/** Whether the SDK's error carries production's answer, so a request reached it even when the browser kept no entry. */ +function carriesAnswer(error: unknown): boolean { + const serverCode = valueAt(error, ['customData', 'serverCode']); + const code = stringAt(error, ['code']); + return isHttpStatus(serverCode) || (code?.startsWith('messaging/token-') ?? false); +} + +/** The status of the last request production refused, if any. */ +function lastFailingStatus(requests: readonly LiveRequest[]): number | undefined { + for (let index = requests.length - 1; index >= 0; index--) { + const status = requests[index]?.status; + if (status !== undefined && status >= 400) return status; + } + return undefined; +} + +/** + * Production's answer for a failed Messaging call, read in order: the + * canonical name of the last failing request's status, then the status an + * Installations error kept, then the SDK's code. + */ +export function messagingCode(error: unknown, requests: readonly LiveRequest[] = []): string { + try { + const failing = lastFailingStatus(requests); + if (failing !== undefined) return statusCode(failing); + const serverStatus = serverStatusOf(error); + if (serverStatus !== undefined) return serverStatus; + return stringAt(error, ['code']) ?? stringAt(error, ['name']) ?? 'unknown'; + } catch { + return 'unknown'; + } +} + +/** What a rejected `getToken` or `deleteToken` adds: its requests and production's code, or no record when it failed locally. */ +export async function messagingRejection(site: ObservedSite, error: unknown): Promise { + const requests = await callRequests(site); + const isLocal = requests !== undefined && requests.length === 0 && !carriesAnswer(error); + if (isLocal) return UNOBSERVED; + return { ...requestsField(requests), code: messagingCode(error, requests) }; +} diff --git a/packages/live/src/modules.ts b/packages/live/src/modules.ts index 25fa926e5..6383cb98f 100644 --- a/packages/live/src/modules.ts +++ b/packages/live/src/modules.ts @@ -33,6 +33,12 @@ * task's settlement, and `getStream` returns a stream that follows the * SDK's. * + * The Messaging rows (`messaging.ts`) observe `getToken` and `deleteToken`, + * whose records list the Installations and FCM registration requests each + * made, and the deliveries of `onMessage` (`firebase/messaging`) and + * `onBackgroundMessage` (`firebase/messaging/sw`). A call that made no + * request makes no record. + * * The real module is passed in. A plane loads it from the app's own installed * `firebase`, so the app object, Auth, Firestore and App Check all come from * one `@firebase/app` component registry. The app object the app holds is the @@ -55,10 +61,20 @@ import { } from './database.js'; import { AI_REFUSED_CALLS, aiCode, aiContext, aiResult, observedChatClass, observedGetGenerativeModel, observedModelClass } from './ai.js'; import { observedGetStream, STORAGE_CALLS, storageContext } from './storage.js'; +import { MESSAGING_CALLS, messagingCode, messagingContext, messagingRejection, messagingResult } from './messaging.js'; import { observedCall, observedListener, type LiveObserver, type ObservedHooks, type ObservedSite, type UncaughtListenerError } from './observe.js'; import { liveCallRefusal, type LiveClient, type RefusedCall } from './refusal.js'; -export type LiveModuleKey = 'live-app' | 'live-auth' | 'live-firestore' | 'live-app-check' | 'live-database' | 'live-ai' | 'live-storage'; +export type LiveModuleKey = + | 'live-app' + | 'live-auth' + | 'live-firestore' + | 'live-app-check' + | 'live-database' + | 'live-ai' + | 'live-storage' + | 'live-messaging' + | 'live-messaging-sw'; type Callback = (this: unknown, ...args: unknown[]) => unknown; @@ -248,6 +264,16 @@ export const LIVE_MODULES: Readonly> = { hooks: { context: storageContext, throwsLocally: true }, replaced: { getStream: observedGetStream }, }, + 'live-messaging': { + calls: MESSAGING_CALLS, + listeners: ['onMessage'], + hooks: { context: messagingContext, codeOf: messagingCode, resultOf: messagingResult, rejectionOf: messagingRejection }, + }, + 'live-messaging-sw': { + calls: [], + listeners: ['onBackgroundMessage'], + hooks: { context: messagingContext }, + }, }; type Wrap = typeof observedCall; diff --git a/packages/live/src/observe.ts b/packages/live/src/observe.ts index 257a219c3..bd00e2630 100644 --- a/packages/live/src/observe.ts +++ b/packages/live/src/observe.ts @@ -15,9 +15,12 @@ * hook (`database.ts`). An AI Logic record also names the model and, on * success, the token counts production reported, read by the module's result * hook (`ai.ts`). A Cloud Storage record also names the bucket and the - * reference's path (`storage.ts`). It never carries an argument's value or a - * result, so no document value, prompt, response text, file content, - * credential or token reaches it. + * reference's path (`storage.ts`). A Cloud Messaging record also lists the + * Installations and FCM registration requests the call made, as API and + * action names, read by the module's result and rejection hooks + * (`messaging.ts`). It never carries an argument's value or a result, so no + * document value, prompt, response text, file content, credential, token or + * push payload reaches it. * * A thenable that is not a promise, as RTDB's `push` and Storage's * `uploadBytesResumable` return, is another exception when the module marks @@ -87,6 +90,21 @@ export interface LiveCallRecord { readonly bucket?: string; /** On an AI Logic call: the model and, on success, production's token counts. Absent on other records. */ readonly ai?: LiveAiRecord; + /** + * On a Cloud Messaging `getToken` or `deleteToken`: the Installations and + * FCM registration requests it made, at most four, in the order they + * started. Absent on other records, and where the realm has no Resource + * Timing. + */ + readonly requests?: readonly LiveRequest[]; +} + +/** One request a Cloud Messaging call made: the API, what the request did, and production's HTTP status. Never its URL. */ +export interface LiveRequest { + readonly api: 'installations' | 'registrations'; + readonly action: 'create' | 'generateAuthToken' | 'update' | 'delete'; + /** The response's HTTP status, absent when Resource Timing reported none. */ + readonly status?: number; } /** The token counts production reported for an AI Logic call. Numbers only. */ @@ -139,10 +157,22 @@ export interface CallContext { readonly ai?: LiveAiRecord; /** When the call started, in ms since epoch, for a result hook that times it. */ readonly startedAt?: number; + /** When the call started on the realm's performance timeline (`performance.now()`), for a hook that reads its Resource Timing entries. */ + readonly timelineStart?: number; + /** On a Cloud Messaging call: the requests it made, once a hook has read them. */ + readonly requests?: readonly LiveRequest[]; } /** What a resolved call's value adds to its record, or `UNOBSERVED` when production never answered it. */ -export type ResultReading = Pick | typeof UNOBSERVED; +export type ResultReading = Pick | typeof UNOBSERVED; + +/** + * What a rejected call's error adds to its record: the requests it made and + * the code production's answer gives, which replaces the module's `codeOf`. + * `UNOBSERVED` when the SDK failed before any request, so production never + * answered. + */ +export type RejectionReading = (Pick & { readonly code?: string }) | typeof UNOBSERVED; /** Reads one call's context from its export name, arguments and `this`, or marks it unobserved. */ export type CallContextOf = (name: string, args: readonly unknown[], self: unknown) => CallContext | typeof UNOBSERVED; @@ -173,6 +203,12 @@ export interface ObservedHooks { * the call's error, as a stream whose response fails is. */ readonly resultOf?: (site: ObservedSite, value: unknown) => ResultReading | Promise; + /** + * Reads a one-shot call's rejection before the record is made: what it + * adds, or `UNOBSERVED` to make none. A promise of the reading defers the + * record until it settles. The app gets the SDK's rejection either way. + */ + readonly rejectionOf?: (site: ObservedSite, error: unknown) => RejectionReading | Promise; /** * Whether a synchronous throw is the SDK's own check, made before any * request, as every Storage export's is (a root reference, a bad argument, @@ -210,6 +246,10 @@ export interface ObservedSite extends ObservedHooks { readonly ai?: LiveAiRecord; /** When the call this site records started, in ms since epoch. */ readonly startedAt?: number; + /** When the call this site records started, on the realm's performance timeline. */ + readonly timelineStart?: number; + /** The Cloud Messaging requests the call this site records made. */ + readonly requests?: readonly LiveRequest[]; /** * Whether the export returns a thenable that is not a promise, whose * settlement is production's answer, as `push` returns its reference. @@ -249,12 +289,13 @@ function billedOf(site: ObservedSite, outcome: LiveCallOutcome, value: unknown): } } -/** The site's instance, bucket, path and AI Logic model, as a record carries them. */ -function placeOf(site: ObservedSite): Pick { +/** The site's instance, bucket, path, AI Logic model and Messaging requests, as a record carries them. */ +function placeOf(site: ObservedSite): Pick { const instance = site.instance === undefined ? {} : { instance: site.instance }; const bucket = site.bucket === undefined ? instance : { ...instance, bucket: site.bucket }; const place = site.path === undefined ? bucket : { ...bucket, path: site.path }; - return site.ai === undefined ? place : { ...place, ai: site.ai }; + const ai = site.ai === undefined ? place : { ...place, ai: site.ai }; + return site.requests === undefined ? ai : { ...ai, requests: site.requests }; } /** `value` is the delivered value or the error, by outcome. */ @@ -365,6 +406,40 @@ function reportResolved(site: ObservedSite, value: unknown): void { ); } +/** What the module's rejection hook reads from an error: an empty reading when it has none, or when it throws. */ +function readRejection(site: ObservedSite, error: unknown): RejectionReading | Promise { + const rejectionOf = site.rejectionOf; + if (rejectionOf === undefined) return {}; + try { + return rejectionOf(site, error); + } catch { + // An error the hook cannot read is still recorded, with the module's code. + return {}; + } +} + +/** Records one reading of a rejected call, or nothing when production never answered it. */ +function reportRejection(site: ObservedSite, error: unknown, reading: RejectionReading): void { + if (reading === UNOBSERVED) return; + const { code, ...added } = reading; + const read = code === undefined ? {} : { codeOf: () => code }; + report({ ...site, ...added, ...read }, 'rejected', error); +} + +/** Records a rejected call with what its error adds: now, or when a deferred reading settles. */ +function reportRejected(site: ObservedSite, error: unknown): void { + const reading = readRejection(site, error); + const isDeferred = reading instanceof Promise; + if (!isDeferred) return reportRejection(site, error, reading); + Promise.prototype.then.call( + reading, + (later: RejectionReading) => reportRejection(site, error, later), + () => { + report(site, 'rejected', error); + }, + ); +} + /** Hands the observer one record, and returns it. Nothing the observer does, or fails to do, reaches the caller. */ function report(site: ObservedSite, outcome: LiveCallOutcome, value?: unknown): LiveCallRecord | undefined { try { @@ -399,7 +474,7 @@ function observeSettlement(site: ObservedSite, result: unknown): unknown { return value; }, (error: unknown) => { - report(site, 'rejected', error); + reportRejected(site, error); throw error; }, ); diff --git a/packages/live/src/page-observer.ts b/packages/live/src/page-observer.ts index d9277882b..6d1f67994 100644 --- a/packages/live/src/page-observer.ts +++ b/packages/live/src/page-observer.ts @@ -1,14 +1,18 @@ /** * The page plane's observer: each live record of a Firestore, Auth, Realtime - * Database, AI Logic or Cloud Storage call becomes the observed call the Node - * host writes to its event stream, with production's answer. Records of - * `firebase/app` and `firebase/app-check` calls stay in the page: the host's - * observed calls name Firestore, Auth, RTDB, AI Logic and Storage only. A - * call that returned synchronously, such as a listener's subscribe, has no - * production answer and is not sent. A call that ran signed in names its - * account: the uid and claim names, never the token. An RTDB call also names - * its instance and path, an AI Logic call its model and production's token - * counts, and a Storage call its bucket and, below the root, its path. + * Database, AI Logic, Cloud Storage or Cloud Messaging call becomes the + * observed call the Node host writes to its event stream, with production's + * answer. Records of `firebase/app` and `firebase/app-check` calls stay in + * the page: the host's observed calls name Firestore, Auth, RTDB, AI Logic, + * Storage and Messaging only. A call that returned synchronously, such as a + * listener's subscribe, has no production answer and is not sent. A call + * that ran signed in names its account: the uid and claim names, never the + * token. An RTDB call also names its instance and path, an AI Logic call its + * model and production's token counts, a Storage call its bucket and, below + * the root, its path, and a Messaging `getToken` or `deleteToken` the + * Installations and FCM registration requests it made, as API and action + * names. `firebase/messaging/sw` records come from a service worker, whose + * observer sends them the same way. * * Loaded in the page, so it imports only types from `@pyric/cli`. */ @@ -24,8 +28,13 @@ const SERVICES: ReadonlyMap = new Map = new Set(['resolved', 'delivered']); const FAILED: ReadonlySet = new Set(['threw', 'rejected', 'listener-error']); @@ -74,6 +83,17 @@ function aiOf(service: ObservedCallService, record: LiveCallRecord): Pick { + const requests = record.requests; + const hasRequests = service === 'messaging' && requests !== undefined && requests.length > 0; + if (!hasRequests) return {}; + return { requests: requests.slice(-MAX_REQUESTS).map((request) => ({ ...request })) }; +} + /** The observed call for a record made at `at`, or null when the host takes no row for it. */ export function observedCallOf(record: LiveCallRecord, at: number): ObservedCall | null { const service = SERVICES.get(record.module); @@ -83,7 +103,7 @@ export function observedCallOf(record: LiveCallRecord, at: number): ObservedCall const project = record.projectId === undefined ? {} : { projectId: record.projectId }; const account = accountOf(record); const billed = record.billedDocuments === undefined ? {} : { billedDocuments: record.billedDocuments }; - return { service, method: record.name, at, outcome, ...project, ...account, ...billed, ...placeOf(service, record), ...aiOf(service, record) }; + return { service, method: record.name, at, outcome, ...project, ...account, ...billed, ...placeOf(service, record), ...aiOf(service, record), ...requestsOf(service, record) }; } /** Sends one observed call to the host. Its rejection is the recorder's, never the app's. */ diff --git a/packages/live/test/messaging-row.test.ts b/packages/live/test/messaging-row.test.ts new file mode 100644 index 000000000..4b5a5372e --- /dev/null +++ b/packages/live/test/messaging-row.test.ts @@ -0,0 +1,333 @@ +/** + * The live-messaging and live-messaging-sw rows over stand-ins with the + * Messaging SDK's signatures. Bun has no service worker, PushManager or + * IndexedDB, so the real `firebase/messaging` cannot mint a token here (it + * runs in the replay's browser test and the live-mode-messaging scenario). + * The stand-in SDK sends no request: each call puts the Resource Timing + * entries capture M recorded for it on a stand-in `PerformanceObserver`, as + * the browser would for the SDK's own `fetch`, and settles as the SDK does. + * + * Each record must name the project and production's answer, list the + * Installations and FCM registration requests the call made (API and action + * only), and carry no token, FID, push key or payload text. A call that made + * no request makes no record. + */ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { observedCallOf } from '../src/page-observer.js'; +import { wrapLiveModule, type LiveCallRecord, type LiveModuleSite, type LiveObserver } from '../src/index.js'; + +/** The host reader and event builder this test uses, loaded at run time so this package's typecheck stays out of them. */ +interface Loaded { + readObservedCall(value: unknown): unknown; + observedCallEvent(call: unknown): { detail?: { requests?: unknown } }; +} + +async function load(): Promise { + return (await import(new URL('../../pyric/src/sandbox/internal/observed-call.ts', import.meta.url).href)) as Loaded; +} + +const loaded = await load(); +const PROJECT = 'demo-live-messaging'; +const INSTALLATIONS = `https://firebaseinstallations.googleapis.com/v1/projects/${PROJECT}/installations`; +const REGISTRATIONS = `https://fcmregistrations.googleapis.com/v1/projects/${PROJECT}/registrations`; +/** Values no record may carry: an FID, an FCM token, the VAPID key, a push key and payload text. */ +const FID = 'cD5xQ7rTnBm2Lk9Wv3YpZa'; +const FCM_TOKEN = 'fcm-token-dGhpcyBpcyBub3QgYSByZWFsIHRva2Vu'; +const VAPID_KEY = 'BOv4pYvapidkeyStandInForTheAppsOwnKey0123456789'; +const PUSH_AUTH = 'q1w2e3r4t5y6u7i8o9p0aZ'; +const PAYLOAD_TEXT = 'order 42 shipped'; +const SECRETS = [FID, FCM_TOKEN, VAPID_KEY, PUSH_AUTH, PAYLOAD_TEXT]; + +/** One Resource Timing entry as the browser reports it for the SDK's `fetch`. */ +interface StandInEntry { + readonly name: string; + readonly entryType: 'resource'; + readonly startTime: number; + readonly responseStatus: number; +} + +/** A stand-in for the browser's `PerformanceObserver`: entries the stand-in SDK reports wait in each observer until taken. */ +class StandInPerformanceObserver { + static readonly live = new Set(); + static readonly supportedEntryTypes = ['resource']; + private pending: StandInEntry[] = []; + constructor(private readonly callback: (list: { getEntries(): StandInEntry[] }) => void) {} + observe(options: { type?: string }): void { + if (options.type === 'resource') StandInPerformanceObserver.live.add(this); + } + disconnect(): void { + StandInPerformanceObserver.live.delete(this); + } + takeRecords(): StandInEntry[] { + const taken = this.pending; + this.pending = []; + return taken; + } + /** Reports one entry, as the browser does when a request ends; the callback runs later, as the browser's task does. */ + static report(name: string, responseStatus: number): void { + const entry: StandInEntry = { name, entryType: 'resource', startTime: performance.now(), responseStatus }; + for (const observer of StandInPerformanceObserver.live) { + observer.pending.push(entry); + setTimeout(() => { + const entries = observer.takeRecords(); + if (entries.length > 0) observer.callback({ getEntries: () => entries }); + }, 5); + } + } +} + +/** An SDK error, shaped as `@firebase/util`'s `FirebaseError`. */ +function sdkError(code: string, customData?: Record): Error { + return Object.assign(new Error(`Messaging: ${code} ${FCM_TOKEN}`), { name: 'FirebaseError', code, ...(customData === undefined ? {} : { customData }) }); +} + +/** What the stand-in SDK's next getToken or deleteToken does: the requests it reports, then how it settles. */ +interface Script { + readonly requests: readonly (readonly [url: string, status: number])[]; + readonly error?: Error; +} + +let next: Script = { requests: [] }; +const pause = () => new Promise((resolve) => setTimeout(resolve, 1)); + +async function runScript(value: unknown): Promise { + const script = next; + await pause(); + for (const [url, status] of script.requests) { + StandInPerformanceObserver.report(url, status); + await pause(); + } + if (script.error !== undefined) throw script.error; + return value; +} + +type Listener = (payload: unknown) => void; +type NextOrObserver = Listener | { next?: Listener }; + +/** The SDK's own reading of `nextOrObserver`: a function, or an observer's `next`. */ +function nextOf(nextOrObserver: NextOrObserver): Listener { + if (typeof nextOrObserver === 'function') return nextOrObserver; + return (payload) => nextOrObserver.next?.(payload); +} +const foreground: Listener[] = []; +const background: Listener[] = []; +const app = { name: '[DEFAULT]', options: { projectId: PROJECT, apiKey: 'test-api-key', appId: '1:1:web:live-messaging', messagingSenderId: '1' } }; +const messaging = { app }; + +/** `firebase/messaging`'s exports, as stand-ins with the SDK's signatures. */ +const realMessaging = { + getMessaging: () => messaging, + isSupported: async () => true, + getToken: (_messaging: unknown, _options?: unknown) => runScript(FCM_TOKEN), + deleteToken: (_messaging: unknown) => runScript(true), + onMessage: (_messaging: unknown, nextOrObserver: NextOrObserver) => { + foreground.push(nextOf(nextOrObserver)); + return () => undefined; + }, +}; + +/** `firebase/messaging/sw`'s exports, as stand-ins with the SDK's signatures. */ +const realMessagingSw = { + getMessaging: () => messaging, + isSupported: async () => true, + onBackgroundMessage: (_messaging: unknown, nextOrObserver: NextOrObserver) => { + background.push(nextOf(nextOrObserver)); + return () => undefined; + }, + experimentalSetDeliveryMetricsExportedToBigQueryEnabled: () => undefined, +}; + +const records: LiveCallRecord[] = []; +const observer: LiveObserver = (record) => { + records.push(record); +}; +const site: LiveModuleSite = { observer, sdkVersion: '12.19.0', client: 'page' }; +const page = wrapLiveModule('live-messaging', 'firebase/messaging', realMessaging, site); +const worker = wrapLiveModule('live-messaging-sw', 'firebase/messaging/sw', realMessagingSw, site); +const realObserver = (globalThis as { PerformanceObserver?: unknown }).PerformanceObserver; + +beforeEach(() => { + (globalThis as { PerformanceObserver?: unknown }).PerformanceObserver = StandInPerformanceObserver; + records.length = 0; +}); + +afterEach(() => { + (globalThis as { PerformanceObserver?: unknown }).PerformanceObserver = realObserver; +}); + +async function until(check: () => boolean): Promise { + const deadline = Date.now() + 2_000; + while (!check()) { + if (Date.now() > deadline) throw new Error('timed out waiting for a record'); + await new Promise((resolve) => setTimeout(resolve, 5)); + } +} + +/** Runs one call with a script, and the records it made once the row has read its requests. */ +async function recorded(script: Script, call: () => Promise): Promise { + next = script; + await call().catch(() => undefined); + await new Promise((resolve) => setTimeout(resolve, 60)); + return [...records]; +} + +/** The observed call the page plane sends for a record, read back by the host's own reader. */ +function hostRead(record: LiveCallRecord): Record { + const call = observedCallOf(record, Date.now()); + expect(call).not.toBeNull(); + return loaded.readObservedCall(JSON.parse(JSON.stringify(call))) as Record; +} + +function expectNoSecrets(value: unknown): void { + const text = JSON.stringify(value); + for (const secret of SECRETS) expect(text).not.toContain(secret); +} + +describe('the live-messaging row records getToken and deleteToken with the requests they made', () => { + test('a fresh getToken: ok, with Installations create and registrations create, and no token', async () => { + const options = { vapidKey: VAPID_KEY }; + next = { requests: [[INSTALLATIONS, 200], [REGISTRATIONS, 200]] }; + const token = await page.getToken(messaging, options); + expect(token).toBe(FCM_TOKEN); + await until(() => records.length === 1); + const [record] = records; + expect(record).toMatchObject({ module: 'firebase/messaging', name: 'getToken', outcome: 'resolved', projectId: PROJECT }); + expect(record?.requests).toEqual([ + { api: 'installations', action: 'create', status: 200 }, + { api: 'registrations', action: 'create', status: 200 }, + ]); + const call = hostRead(record!); + expect(call).toMatchObject({ service: 'messaging', method: 'getToken', outcome: { status: 'ok' }, projectId: PROJECT }); + expect(loaded.observedCallEvent(call).detail).toEqual({ requests: record!.requests }); + expectNoSecrets([record, call]); + }); + + test('a getToken the registrations API answers 403: permission_denied, read from the request', async () => { + const error = sdkError('messaging/token-subscribe-failed'); + const [record] = await recorded({ requests: [[INSTALLATIONS, 200], [REGISTRATIONS, 403]], error }, () => page.getToken(messaging)); + expect(record).toMatchObject({ name: 'getToken', outcome: 'rejected', code: 'permission_denied' }); + expect(record?.requests).toEqual([ + { api: 'installations', action: 'create', status: 200 }, + { api: 'registrations', action: 'create', status: 403 }, + ]); + expect(hostRead(record!)).toMatchObject({ outcome: { status: 'error', code: 'permission_denied' } }); + expectNoSecrets(record); + }); + + test('an Installations denial whose responseStatus reads 0: the status is left out and the code is the server status the SDK kept', async () => { + const error = sdkError('installations/request-failed', { requestName: 'Create Installation', serverCode: 400, serverStatus: 'INVALID_ARGUMENT', serverMessage: 'API key not valid.' }); + const [record] = await recorded({ requests: [[INSTALLATIONS, 0]], error }, () => page.getToken(messaging)); + expect(record).toMatchObject({ outcome: 'rejected', code: 'invalid_argument', requests: [{ api: 'installations', action: 'create' }] }); + expect(record?.requests?.[0]).not.toHaveProperty('status'); + hostRead(record!); + }); + + test('an Installations denial with no Resource Timing entry is still recorded, with the server status the SDK kept', async () => { + const error = sdkError('installations/request-failed', { requestName: 'Create Installation', serverCode: 403, serverStatus: 'PERMISSION_DENIED' }); + const [record] = await recorded({ requests: [], error }, () => page.getToken(messaging)); + expect(record).toMatchObject({ outcome: 'rejected', code: 'permission_denied' }); + expect(record).not.toHaveProperty('requests'); + hostRead(record!); + }); + + test('a getToken that refreshes its stored token: registrations update', async () => { + const [record] = await recorded({ requests: [[`${REGISTRATIONS}/${FCM_TOKEN}`, 200]] }, () => page.getToken(messaging)); + expect(record?.requests).toEqual([{ api: 'registrations', action: 'update', status: 200 }]); + expectNoSecrets(record); + }); + + test('a getToken that replaces its stored token: registrations delete, then create, and Installations generateAuthToken', async () => { + const script: Script = { requests: [[`${INSTALLATIONS}/${FID}/authTokens:generate`, 200], [`${REGISTRATIONS}/${FCM_TOKEN}`, 200], [REGISTRATIONS, 200]] }; + const [record] = await recorded(script, () => page.getToken(messaging)); + expect(record?.requests).toEqual([ + { api: 'installations', action: 'generateAuthToken', status: 200 }, + { api: 'registrations', action: 'delete', status: 200 }, + { api: 'registrations', action: 'create', status: 200 }, + ]); + expectNoSecrets(record); + }); + + test('more than four requests in a call\'s window: the record keeps the last four, which a host takes', async () => { + const script: Script = { requests: [[INSTALLATIONS, 200], [INSTALLATIONS, 200], [`${INSTALLATIONS}/${FID}/authTokens:generate`, 200], [`${INSTALLATIONS}/${FID}/authTokens:generate`, 200], [REGISTRATIONS, 200], ['https://example.com/other', 200]] }; + const [record] = await recorded(script, () => page.getToken(messaging)); + expect(record?.requests).toHaveLength(4); + expect(record?.requests?.at(-1)).toEqual({ api: 'registrations', action: 'create', status: 200 }); + hostRead(record!); + }); + + test('deleteToken: ok, with registrations delete', async () => { + const [record] = await recorded({ requests: [[`${REGISTRATIONS}/${FCM_TOKEN}`, 200]] }, () => page.deleteToken(messaging)); + expect(record).toMatchObject({ name: 'deleteToken', outcome: 'resolved', projectId: PROJECT, requests: [{ api: 'registrations', action: 'delete', status: 200 }] }); + expect(hostRead(record!)).toMatchObject({ service: 'messaging', method: 'deleteToken', outcome: { status: 'ok' } }); + expectNoSecrets(record); + }); + + test('a getToken answered from the stored token, and a deleteToken with none stored, make no record', async () => { + expect(await recorded({ requests: [] }, () => page.getToken(messaging))).toEqual([]); + expect(await recorded({ requests: [] }, () => page.deleteToken(messaging))).toEqual([]); + }); + + test('a getToken the SDK refuses before any request makes no record', async () => { + expect(await recorded({ requests: [], error: sdkError('messaging/permission-blocked') }, () => page.getToken(messaging))).toEqual([]); + }); + + test('the app gets the SDK\'s own value and error', async () => { + next = { requests: [[INSTALLATIONS, 200], [REGISTRATIONS, 200]] }; + expect(await page.getToken(messaging)).toBe(FCM_TOKEN); + const error = sdkError('messaging/token-subscribe-failed'); + next = { requests: [[REGISTRATIONS, 403]], error }; + await expect(page.getToken(messaging)).rejects.toBe(error); + await until(() => records.length === 2); + }); + + test('without PerformanceObserver, every settled call is recorded, without requests', async () => { + delete (globalThis as { PerformanceObserver?: unknown }).PerformanceObserver; + const [record] = await recorded({ requests: [] }, () => page.getToken(messaging)); + expect(record).toMatchObject({ name: 'getToken', outcome: 'resolved', projectId: PROJECT }); + expect(record).not.toHaveProperty('requests'); + hostRead(record!); + }); + + test('isSupported makes no record', async () => { + expect(await page.isSupported()).toBe(true); + expect(await worker.isSupported()).toBe(true); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(records).toEqual([]); + }); +}); + +describe('the live-messaging rows record one row per delivery the SDK hands the app', () => { + const payload = { from: '1', messageId: FCM_TOKEN, notification: { title: PAYLOAD_TEXT }, data: { auth: PUSH_AUTH, fid: FID } }; + + test('onMessage: one delivered record per foreground message, and no payload text', () => { + const seen: unknown[] = []; + page.onMessage(messaging, (message) => seen.push(message)); + foreground.at(-1)?.(payload); + foreground.at(-1)?.(payload); + expect(seen).toEqual([payload, payload]); + const delivered = records.filter((record) => record.outcome === 'delivered'); + expect(delivered).toHaveLength(2); + expect(delivered[0]).toMatchObject({ module: 'firebase/messaging', name: 'onMessage', projectId: PROJECT }); + expect(hostRead(delivered[0]!)).toMatchObject({ service: 'messaging', method: 'onMessage', outcome: { status: 'ok' } }); + expectNoSecrets(records); + }); + + test('onBackgroundMessage: one delivered record per background message, and no payload text', () => { + const seen: unknown[] = []; + worker.onBackgroundMessage(messaging, { next: (message: unknown) => seen.push(message) }); + background.at(-1)?.(payload); + expect(seen).toEqual([payload]); + const delivered = records.filter((record) => record.outcome === 'delivered'); + expect(delivered).toHaveLength(1); + expect(delivered[0]).toMatchObject({ module: 'firebase/messaging/sw', name: 'onBackgroundMessage', projectId: PROJECT }); + expect(hostRead(delivered[0]!)).toMatchObject({ service: 'messaging', method: 'onBackgroundMessage' }); + expectNoSecrets(records); + }); + + test('a subscribe call sends no row', () => { + page.onMessage(messaging, () => undefined); + const subscribed = records.filter((record) => record.outcome === 'returned'); + expect(subscribed).toHaveLength(1); + expect(observedCallOf(subscribed[0]!, Date.now())).toBeNull(); + }); +});