Files
crowd-funder-for-time-pwa/src/services/notifications/firebaseMessagingClient.ts
T
Jose Olarte III 0d7586865c feat(notifications): allow auth bypass for local debug and ngrok testing
Add shouldBypassNotificationAuth() when test mode or a backend URL override
is set so register/refresh can proceed without DID/Bearer headers. Production
paths still require auth when bypass is off; log bypass vs authenticated
request modes for easier WAKEUP_PING and panel smoke testing.
2026-05-20 19:34:46 +08:00

315 lines
9.0 KiB
TypeScript

/**
* Firebase Cloud Messaging (JS SDK) + Capacitor Push Notifications (native bridge).
*
* Initializes the Firebase web app when VITE_FIREBASE_* env vars are set, wires
* Capacitor push listeners, requests permission before registration/token flow,
* and attaches Firebase messaging when the browser/WebView reports support.
*/
import { Capacitor } from "@capacitor/core";
import { PushNotifications } from "@capacitor/push-notifications";
import {
type FirebaseApp,
type FirebaseOptions,
getApps,
initializeApp,
} from "firebase/app";
import {
getMessaging,
getToken,
isSupported,
onMessage,
} from "firebase/messaging";
import { logger } from "@/utils/logger";
import { handleCapacitorPushNotificationReceived } from "./NativeNotificationService";
import { getNotificationApiHeaders } from "./notificationApiAuth";
import { deferFcmRegistration } from "./notificationAuthLifecycle";
import { registerToken } from "./NotificationService";
import {
logPushNotificationActionPerformed,
logPushNotificationReceived,
logTokenRegistrationSkippedDuplicate,
} from "./notificationLog";
const LOG = "[FirebaseMessaging]";
let firebaseAppSingleton: FirebaseApp | null = null;
let nativeInitPromise: Promise<void> | null = null;
/** Avoid duplicate POSTs when the same token is delivered more than once. */
let lastRegisteredFcmToken: string | null = null;
/** Last token received from Capacitor/Firebase (may match registered). */
let lastSeenFcmToken: string | null = null;
async function registerRetrievedToken(
token: string,
options?: { force?: boolean },
): Promise<void> {
const trimmed = token.trim();
if (!trimmed) {
return;
}
lastSeenFcmToken = trimmed;
if (!options?.force && trimmed === lastRegisteredFcmToken) {
logTokenRegistrationSkippedDuplicate(trimmed);
return;
}
const auth = await getNotificationApiHeaders("register");
if (!auth.ok) {
if (options?.force) {
throw new Error(`FCM registration auth unavailable: ${auth.message}`);
}
deferFcmRegistration(trimmed);
return;
}
await registerToken(trimmed);
lastRegisteredFcmToken = trimmed;
}
/** Most recent FCM token from native/web push registration (for debug UI). */
export function getLastKnownFcmToken(): string | null {
return lastSeenFcmToken ?? lastRegisteredFcmToken;
}
/**
* Re-runs token registration immediately (debug). Bypasses duplicate-token skip.
*/
export async function reregisterFcmTokenNow(): Promise<string> {
if (!Capacitor.isNativePlatform()) {
throw new Error("FCM registration is only available on native platforms");
}
lastRegisteredFcmToken = null;
const cached = lastSeenFcmToken?.trim();
if (cached) {
await registerRetrievedToken(cached, { force: true });
return cached;
}
const app = ensureFirebaseApp();
if (app && (await isSupported())) {
const messaging = getMessaging(app);
const vapidKey = import.meta.env.VITE_FIREBASE_VAPID_KEY as
| string
| undefined;
const token = await getToken(
messaging,
vapidKey ? { vapidKey } : undefined,
);
if (!token?.trim()) {
throw new Error("Firebase getToken returned an empty token");
}
await registerRetrievedToken(token, { force: true });
return token.trim();
}
return new Promise<string>((resolve, reject) => {
const timeoutMs = 15_000;
const timeoutId = window.setTimeout(() => {
void listenerPromise.then((h) => h.remove());
reject(new Error("Timed out waiting for push registration token"));
}, timeoutMs);
const listenerPromise = PushNotifications.addListener(
"registration",
(token) => {
window.clearTimeout(timeoutId);
void listenerPromise.then((h) => h.remove());
const value = token.value?.trim() ?? "";
if (!value) {
reject(new Error("Capacitor registration returned an empty token"));
return;
}
void registerRetrievedToken(value, { force: true })
.then(() => resolve(value))
.catch(reject);
},
);
void PushNotifications.register().catch((err) => {
window.clearTimeout(timeoutId);
void listenerPromise.then((h) => h.remove());
reject(err);
});
});
}
function readFirebaseOptions(): FirebaseOptions | null {
const env = import.meta.env;
const apiKey = env.VITE_FIREBASE_API_KEY as string | undefined;
const projectId = env.VITE_FIREBASE_PROJECT_ID as string | undefined;
const appId = env.VITE_FIREBASE_APP_ID as string | undefined;
const messagingSenderId = env.VITE_FIREBASE_MESSAGING_SENDER_ID as
| string
| undefined;
if (!apiKey || !projectId || !appId || !messagingSenderId) {
logger.debug(
`${LOG} Missing one or more VITE_FIREBASE_* keys; Firebase app not initialized`,
);
return null;
}
const authDomain =
(env.VITE_FIREBASE_AUTH_DOMAIN as string | undefined) ||
`${projectId}.firebaseapp.com`;
const storageBucket =
(env.VITE_FIREBASE_STORAGE_BUCKET as string | undefined) ||
`${projectId}.appspot.com`;
const opts: FirebaseOptions = {
apiKey,
authDomain,
projectId,
storageBucket,
messagingSenderId,
appId,
};
const measurementId = env.VITE_FIREBASE_MEASUREMENT_ID as string | undefined;
if (measurementId) {
opts.measurementId = measurementId;
}
return opts;
}
/**
* Ensures a single Firebase app instance for the client when config is present.
*/
export function ensureFirebaseApp(): FirebaseApp | null {
if (firebaseAppSingleton) {
return firebaseAppSingleton;
}
const options = readFirebaseOptions();
if (!options) {
return null;
}
firebaseAppSingleton =
getApps().length > 0 ? getApps()[0]! : initializeApp(options);
logger.info(`${LOG} Firebase app initialized`);
return firebaseAppSingleton;
}
async function attachFirebaseMessagingIfSupported(
app: FirebaseApp,
): Promise<void> {
if (!(await isSupported())) {
logger.debug(
`${LOG} firebase/messaging not supported in this context; skipping getMessaging`,
);
return;
}
const messaging = getMessaging(app);
const vapidKey = import.meta.env.VITE_FIREBASE_VAPID_KEY as
| string
| undefined;
try {
const token = await getToken(
messaging,
vapidKey ? { vapidKey } : undefined,
);
logger.info(`${LOG} Firebase getToken completed`, {
tokenPrefix: token ? `${token.slice(0, 12)}…` : "(empty)",
});
await registerRetrievedToken(token);
} catch (err) {
logger.warn(
`${LOG} Firebase getToken failed (common on native WebView without SW)`,
err,
);
}
onMessage(messaging, (payload) => {
logger.debug(`${LOG} onMessage (foreground)`, payload);
});
}
/**
* Native: register Capacitor push listeners, request permissions, register for push,
* then initialize Firebase Messaging when env config and platform support allow.
*/
async function initializeNativePushAndFirebaseMessagingImpl(): Promise<void> {
if (!Capacitor.isNativePlatform()) {
return;
}
try {
const app = ensureFirebaseApp();
await PushNotifications.addListener("registration", (token) => {
if (token.value?.trim()) {
lastSeenFcmToken = token.value.trim();
}
logger.info(`${LOG} Capacitor registration token`, {
valuePrefix: token.value ? `${token.value.slice(0, 12)}…` : "(empty)",
});
void registerRetrievedToken(token.value).catch((err) => {
logger.warn(
`${LOG} registerToken after Capacitor registration failed`,
err,
);
});
});
await PushNotifications.addListener("registrationError", (err) => {
logger.error(`${LOG} registrationError`, err);
});
await PushNotifications.addListener(
"pushNotificationReceived",
(notification) => {
logger.debug(`${LOG} pushNotificationReceived`, notification);
logPushNotificationReceived(notification);
void handleCapacitorPushNotificationReceived(notification).catch(
(err) => {
logger.warn(
`${LOG} handleCapacitorPushNotificationReceived failed`,
err,
);
},
);
},
);
await PushNotifications.addListener(
"pushNotificationActionPerformed",
(action) => {
logger.debug(`${LOG} pushNotificationActionPerformed`, action);
logPushNotificationActionPerformed(action);
},
);
const perm = await PushNotifications.requestPermissions();
if (perm.receive !== "granted") {
logger.warn(`${LOG} Push permission not granted`, perm);
return;
}
await PushNotifications.register();
if (app) {
await attachFirebaseMessagingIfSupported(app);
}
} catch (err) {
logger.error(`${LOG} Native push / Firebase messaging init failed`, err);
}
}
/**
* Idempotent startup hook for Capacitor iOS/Android.
*/
export function initializeNativePushAndFirebaseMessaging(): Promise<void> {
if (!Capacitor.isNativePlatform()) {
return Promise.resolve();
}
if (!nativeInitPromise) {
nativeInitPromise = initializeNativePushAndFirebaseMessagingImpl();
}
return nativeInitPromise;
}