Guidance for an AI agent making changes in this repository. For user-facing docs see
README.md; for per-class API reference see wiki/. This file covers
what those don't: layout, conventions, and the design decisions that are expensive to
rediscover from the diff alone.
A Firebase client meant to run in any JavaScript environment, not just a browser or
Node.js-as-admin. The constraint that shapes every module: there are no browser globals to
lean on. Storage, logging and crypto are all supplied by the consumer through FirebaseApp
(see wiki/FirebaseApp.md), and every module namespaces its own keys
inside that one storage instance so several modules — or several apps — can share it.
yarn lint— ESLint oversrc/(flat config ineslint.config.js).yarn build— cleansdist/andtsconfig.tsbuildinfo, regenerates the protobuf JS/typings (yarn protobuf), then runstscand copies the protobuf output intodist/protobuf.yarn protobuf— regeneratessrc/protobuf/protos.jsandprotos.d.tsfrom the.protofiles. Run this (viayarn build) after touching anysrc/protobuf/*.protofile.yarn test— runsyarn buildfirst, thennode --test test/*.test.js. The suite is plainnode:test, currently a single file,test/reliability.test.js. It requires against../dist, so it exercises compiled output, notsrc/directly — a source-only edit that isn't built is invisible to the tests. The test file installs a module-levelfetchHandlervariable and overridesglobal.fetchonce at load time to call it, so individual tests swap behaviour by reassigningfetchHandler, not by re-mockingfetch.yarn precommit—yarn lint && yarn test. Run this before considering a change done.yarn npm publish --tag experimental— publishes the package. Yarn 4 ignorespublishConfig.tagfrompackage.json(verified:yarn npm publish --dry-runprinted "with tag latest" despitepublishConfig.tag: "experimental"being set), so the tag has to be passed explicitly on the command line. Runyarn npm publish --dry-runfirst to inspect the file list that will actually be published.
src/app.ts—FirebaseApp, the shared instance every other class takes as its first dependency. Holdscredentials,storage(wrapped to prefix every key withcredentials.appId),logger(defaults toconsole) andcrypto(defaults toglobalThis.crypto). Also exportsassertRequiredProperties(), see Conventions.src/installations.ts— Firebase Installations (FID + auth token) and the heartbeat cache/ header used by other requests. See Invariants below; this is the module with the most non-obvious behaviour in the codebase.src/remoteConfig.ts— Remote Config fetch/activate, cached in storage with anetag, refreshed on an interval, one in-flight fetch shared via a semaphore promise.src/analytics.ts— GA4 event logging through the sameg/collectendpointgtag.jsuses.src/pushReceiver.ts—PushReceiver: composeslib/gcm.ts+lib/fcm.tsfor registration, then owns the persistent MCS TLS connection (login, heartbeat, retry/backoff, message decryption viautils/decrypt.ts, persistent-id dedup) usinglib/parser.tsto frame incoming bytes andprotobuf/for the wire messages.src/pushReceiverLegacy.ts—PushReceiver(exported asPushReceiverLegacyfromindex.ts) wrapped with the flat config/credentials shape of@eneris/push-receiver. See Invariants below for the storage adapter it injects.src/pushSender.ts— sends via the FCM HTTP v1 API using a service account JWT; independent of every other module (noFirebaseApp, no Installations).src/lib/gcm.ts— GCM checkin + registration (android.clients.google.com). Produces theandroidId/securityToken/tokenthat MCS login and FCM registration both depend on.src/lib/fcm.ts— FCM web-push registration (create/update/delete againstfcmregistrations.googleapis.com), keyed off a GCM registration and a Firebase Installation. See Invariants below forinstallationFidstamping.src/lib/parser.ts— turns raw MCS TLS bytes into tagged protobuf messages (Parser), mirroring Chromium'sWaitForDatastate machine (see the comment at the top of the file for what deliberately differs).src/utils/—fetch.ts(retry policy for fetch-based calls, see Invariants),request.ts(an older, separate linear-retry helper used only by the GCM checkin/registration andPushSenderpaths, which still use plainfetchdirectly),decrypt.ts(web push aesgcm decryption),base64.ts,value.ts(Value, used byRemoteConfig.getValue/getAll),constants.ts(MCS/GCM protocol enums),defer.ts,timeout.ts.src/protobuf/—.protosources plus generatedprotos.js/protos.d.ts(regenerated byyarn protobuf, do not hand-edit) andindex.ts, which wires upLongfor protobufjs.
- Four-space indentation, no semicolons (see any
src/*.tsfile for the prevailing style). - Private state uses real private
#fields, not TypeScript'sprivatekeyword, except for a few pre-existingprivatemethods left as-is. - Every class that takes
FirebaseApp(or another dependency object) callsassertRequiredProperties()(fromsrc/app.ts) at construction, listing everyapp.*-rooted property path the class actually reads (e.g.'credentials.apiKey','storage.get','logger.warn'). Keep this list in sync when you add or remove a use ofapp.*inside the class — it's the only thing that turns a missing dependency into a clear constructor-time error instead of a runtimeundefinedcrash deep in a request. - Storage access is never ad hoc: each class builds a small local
#storageobject in its constructor that namespaces every key under its own prefix (`installations.${key}`,`fcm.${key}`, …) and delegates toapp.storage.get/set. Add new persisted fields through that wrapper, not by callingapp.storagedirectly. - Defaults are applied once, in the constructor of the class that owns the concept (push
config, for example, is owned by
PushReceiver, notFirebaseApp), by spreading the matchingDEFAULT_*constants fromutils/constants.tsunder the caller's options. Dependents (GCM,FCM) take the already-resolved object as a constructor parameter, assert it themselves, and read it with!. There is no??fallback at a read site. - Comments explain why, not what. Keep it that way — see the next section for what "why" is worth knowing here that isn't in the diff.
These are the parts of the design that cost real time to reconstruct from the code alone.
Read this section before touching installations.ts, lib/fcm.ts, utils/fetch.ts or
pushReceiverLegacy.ts.
getInstallation() has to decide, on every call, whether to serve the stored auth token,
refresh it, or throw — while never losing a working FID/refresh token to a request that
merely failed. The policy:
- Refresh margin. A stored token is treated as expired
INSTALLATION_REFRESH_MARGIN(one hour) before it actually expires, so a refresh always has headroom before the real deadline. - Authoritative invalidity. Only a
401or404from the refresh (auth token generate) request means "FIS no longer knows this installation" (isInstallationInvalid()/InstallationsRequestError.status). Every other failure — network error, timeout,429,5xx,400,403, or a response body that failsparseAuthTokenResponse()— is treated as transient and must never discard stored credentials. - Self-heal without data loss. On an authoritative 401/404,
#getInstallation()requests a brand new installation (#requestNewInstallation()) and only overwrites storage (#create({ replace: true })) once that request succeeds.#requestNewInstallation()itself never reads or writes storage, so a failed replacement can never discard a working FID or refresh token — the old entry is still there to retry with next time. - Serve-stale-while-usable. On a transient failure, the stored token is still returned as
long as it is "genuinely usable" (
#isUsableNow(), expiry marginINSTALLATION_MIN_USABLE_MS, one minute) — long enough to outlive the request about to use it. Only once the token is within a minute of (or past) its real expiry does the transient failure get rethrown to the caller. - Refresh cool-down. A failed refresh starts a 60 second cool-down
(
INSTALLATION_REFRESH_COOLDOWN_MS, perFirebaseAppinstance, in-memory only). While on cool-down and the stored token is still usable,#getInstallation()skips the network call entirely instead of paying the fullfetchWithRetrybackoff again. - Backwards-clock guard.
isInstallationExpired()also treats an entry as expired if its remaining lifetime is implausibly long — more thanMAX_INSTALLATION_LIFETIME(7 days, the real FIS token lifetime) past the refresh margin. FIS tokens can't legitimately outlive a fresh token by that much, so a value like that means the system clock moved backwards or the entry came from a restored backup; the fix is to refresh and let FIS overwriteexpiresAtwith a sane value, using the constant margin regardless of how the caller was invoked — a bogus entry is bogus at any margin.
Every FcmData created or renewed by #register()/#refreshRegistration() is stamped with
installationFid, the FID it was created under. On every subsequent getRegistration() call,
#refreshRegistration() compares that stamp against installations.storedFid before doing
anything else:
- A mismatch — including a stored installation that no longer exists at all — means FIS
replaced or deleted the installation this registration belongs to (self-heal on a
rejected FID, or an explicit
deleteInstallation()). The old registration is dead server-side, so#refreshRegistration()re-registers from scratch rather than trying to patch or delete the stale one. A missing stored FID counts as a mismatch too — otherwise a deleted installation would keep being served from the zero-I/O steady-state path until the 7 day renewal check finally caught it. - A missing stamp on the stored registration (only possible via
pushReceiverLegacy.ts'sLegacyStorageAdapter#getCurrentFcmRegistration(), which has no FID to supply) means "adopt the current FID without re-registering" — so a consumer upgrading from@eneris/push-receiverdoesn't have every existing FCM token rotate on upgrade.
#refreshRegistration() fetches the installation (this.#app.installations.getInstallation())
only inside the branches that actually issue a request (the subscription-options-changed
branch and the token-renewal branch) — deliberately not once up front. This means a cached,
already-valid registration lets PushReceiver.connect() succeed even while Firebase
Installations itself is down. Do not hoist that call back to the top of the function.
fetch-retry's own retries option is silently ignored whenever retryOn is a function
(only the array form of retryOn enforces it) — this library always passes a function, so
every bound has to be applied by hand inside that function:
- A rejected request (network error, or a malformed request, which
fetchalso rejects with aTypeErrorand which is indistinguishable from a real network failure here) retries unbounded, with exponential backoff capped atMAX_RETRY_DELAY_MS(10 minutes). This is deliberate: an offline machine can stay offline for a while, and the request should complete once connectivity returns rather than fail permanently. - A 429 or 5xx response stops retrying after
HTTP_RETRIES(3) attempts, so the caller actually gets an answer and its own failure handling (see the Installations policy above) runs instead of hanging forever behind an unbounded retry.
src/utils/request.ts is a separate, older, linear-retry helper (fixed step, fixed max
retries) used only by the GCM checkin/registration path (lib/gcm.ts) and PushSender. It
was not folded into fetchWithRetry; don't assume the two share behaviour.
It exists to let @eneris/push-receiver consumers switch without migrating their stored
credentials shape. Its LegacyStorageAdapter implements the current StorageInterface but
backs it with the old flat Credentials object: every read/write of
installations.installation and fcm.registration is intercepted and translated field by
field into/out of the legacy shape (legacyInstallationToCurrent/currentInstallationToLegacy,
LegacyStorageAdapter#getCurrentFcmRegistration()/currentFcmToLegacy). Two consequences
that are easy to miss:
- Any new optional field added to
FcmData(src/lib/fcm.ts) is silently dropped when it passes through this adapter, because the translation is done field by field, not by passing the object through. If a new field's absence changes behaviour (the way a missinginstallationFiddoes, see above), that behaviour has to hold up under this shim too. - Anything written to
installations.installationorfcm.registrationmust tolerate being cleared (undefined):deleteInstallation()ininstallations.tswritesundefinedto clear the entry, and#setInstallation()here has to accept that without trying to shoveundefinedinto the legacyInstallationDatashape.
PushReceiver's persistent MCS connection (connect(), login, heartbeat) depends only on GCM
checkin/registration (lib/gcm.ts, androidId/securityToken), never on Firebase
Installations. FIS only comes into play through lib/fcm.ts's registration flow. A FIS outage
can therefore still allow a message-receiving connection to work (see the refreshRegistration
short-circuit above) — don't introduce a dependency from the MCS/login path back onto
installations.getInstallation().
Large parts of installations.ts and utils/fetch.ts intentionally mirror
firebase-js-sdk. Where the mirroring is close
enough to matter, the code carries a Source: URL at the point of use; this table collects
them along with the one thing worth knowing about each: what's mirrored, and where this
library deliberately deviates.
| Area | Upstream source | Mirrors | Deviates |
|---|---|---|---|
Heartbeat limits, payload version (BUILD_TARGET, MAX_HEADER_BYTES, MAX_NUM_STORED_HEARTBEATS, HEARTBEAT_VERSION) |
packages/app/src/heartbeatService.ts |
Constants and cache/header algorithm (trigger, getHeader, extractHeartbeatsForHeader, countBytes, getEarliestHeartbeatIdx, UTC date key) |
Backed by this library's storage abstraction instead of IndexedDB |
Platform logger string (HEARTBEAT_AGENT) |
registerCoreComponents.ts, platformLoggerService.ts |
The fire-*/version token format |
Hand-built Node/CommonJS approximation, not a verbatim upstream literal |
Heartbeat cache shape (HeartbeatCacheEntry) |
packages/app/src/types.ts |
Persisted shape (lastSentHeartbeatDate, heartbeats) |
Stored via app.storage, not IndexedDB |
Authoritative-invalidity signal (InstallationsRequestError, isInstallationInvalid) |
functions/common.ts, helpers/refresh-auth-token.ts |
401/404 → installation gone, everything else transient | Self-heal (create the replacement) happens inline in the same getInstallation() call instead of being deferred to the next call |
x-firebase-client heartbeat header attachment |
create-installation-request.ts, generate-auth-token-request.ts |
Attached on create + refresh, not on delete | — |
Empty/omitted fid handling on create |
create-installation-request.ts |
Server allocates a FID when the request's is omitted/empty; the requested FID is kept if the response doesn't echo one | This library additionally validates the response (expiresIn parsing, non-empty fid/refreshToken/token) and rejects an unusable body instead of storing it |
fetch-retry's retryOn-as-function bypasses its own retries option |
fetch-retry source, node_modules/fetch-retry/index.js |
— (this is a fetch-retry behaviour note, not a firebase-js-sdk one) |
Bounds applied by hand: unbounded retry on network error, 3 retries on 429/5xx (upstream firebase-js-sdk's own retry logic is not mirrored here at all) |
The general deviations worth remembering across all of the above: this library validates
expiresIn and response bodies more strictly than upstream, self-heals inside the same call
rather than waiting for the next one, and lets network-error retries run unbounded rather than
capping them like the HTTP-status retries.
- Tests run against
dist/.test/reliability.test.jsdoesrequire('../dist'); a source change with no rebuild is invisible toyarn test(this is whyyarn testrunsyarn buildfirst — don't try to skip that when iterating). deleteInstallation()writesundefined. It clears the stored key by setting it toundefined, not by removing it — any consumer-supplied storageset()implementation must tolerate being called with anundefinedvalue.- The unbounded network retry in
utils/fetch.tshas no abort path.destroy()(e.g. onRemoteConfigorPushReceiver) does not cancel an in-flight request stuck in that retry loop, and the pending retry timer keeps the event loop alive until the request eventually settles. src/index.tsre-exports matter forinstanceof. Anything a consumer needs to run aninstanceofcheck against (e.g.InstallationsRequestError) must be re-exported fromsrc/index.ts, not just from the module that defines it.