v0.12.14 FCM optimization

This commit is contained in:
2026-03-24 08:22:56 -04:00
parent bb5a3b6813
commit 117b5cbe4c
7 changed files with 251 additions and 44 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "rosterchirp-backend",
"version": "0.12.13",
"version": "0.12.14",
"description": "RosterChirp backend server",
"main": "src/index.js",
"scripts": {

View File

@@ -13,7 +13,7 @@
# ─────────────────────────────────────────────────────────────
set -euo pipefail
VERSION="${1:-0.12.13}"
VERSION="${1:-0.12.14}"
ACTION="${2:-}"
REGISTRY="${REGISTRY:-}"
IMAGE_NAME="rosterchirp"

View File

@@ -1,6 +1,6 @@
{
"name": "rosterchirp-frontend",
"version": "0.12.13",
"version": "0.12.14",
"private": true,
"scripts": {
"dev": "vite",

View File

@@ -0,0 +1,59 @@
// ── Unified Push Handler (Optimized for Mobile) ──────────────────────────────
self.addEventListener('push', (event) => {
console.log('[SW] Push event received. Messaging Ready:', !!messaging);
// event.waitUntil is the "Keep-Alive" signal for mobile OS
event.waitUntil(
(async () => {
try {
let payload;
// 1. Try to parse the data directly from the push event (Fastest/Reliable)
if (event.data) {
try {
payload = event.data.json();
console.log('[SW] Raw push data parsed:', JSON.stringify(payload));
} catch (e) {
console.warn('[SW] Could not parse JSON, using text fallback');
payload = { notification: { body: event.data.text() } };
}
}
// 2. If the payload is empty, check if Firebase can catch it
// (This happens if your server sends "Notification" instead of "Data" messages)
if (!payload && messaging) {
// This is a last-resort wait for the SDK
payload = await new Promise((resolve) => {
const timeout = setTimeout(() => resolve(null), 2000);
messaging.onBackgroundMessage((bgPayload) => {
clearTimeout(timeout);
resolve(bgPayload);
});
});
}
// 3. Construct and show the notification
if (payload) {
const n = payload.notification || {};
const d = payload.data || {};
// Use the specific function you already defined
await showRosterChirpNotification({
title: n.title || d.title || 'New Message',
body: n.body || d.body || '',
url: d.url || d.link || '/', // some SDKs use 'link'
groupId: d.groupId || '',
});
} else {
// Fallback if we woke up for a "ghost" push with no data
await self.registration.showNotification('RosterChirp', {
body: 'You have a new update.',
tag: 'rosterchirp-fallback'
});
}
} catch (error) {
console.error('[SW] Critical Push Error:', error);
}
})()
);
});

View File

@@ -0,0 +1,82 @@
The Consolidated "Bulletproof" Push Listener
To fix the "hit or miss" behavior on mobile, we need to move away from relying on the Firebase SDK's internal listener (which is a black box that doesn't always play nice with mobile power management) and instead wrap everything in the native push event using event.waitUntil.
Replace your current messaging.onBackgroundMessage and self.addEventListener('push') blocks with this unified version:
JavaScript
// ── Unified Push Handler (Optimized for Mobile) ──────────────────────────────
self.addEventListener('push', (event) => {
console.log('[SW] Push event received. Messaging Ready:', !!messaging);
// event.waitUntil is the "Keep-Alive" signal for mobile OS
event.waitUntil(
(async () => {
try {
let payload;
// 1. Try to parse the data directly from the push event (Fastest/Reliable)
if (event.data) {
try {
payload = event.data.json();
console.log('[SW] Raw push data parsed:', JSON.stringify(payload));
} catch (e) {
console.warn('[SW] Could not parse JSON, using text fallback');
payload = { notification: { body: event.data.text() } };
}
}
// 2. If the payload is empty, check if Firebase can catch it
// (This happens if your server sends "Notification" instead of "Data" messages)
if (!payload && messaging) {
// This is a last-resort wait for the SDK
payload = await new Promise((resolve) => {
const timeout = setTimeout(() => resolve(null), 2000);
messaging.onBackgroundMessage((bgPayload) => {
clearTimeout(timeout);
resolve(bgPayload);
});
});
}
// 3. Construct and show the notification
if (payload) {
const n = payload.notification || {};
const d = payload.data || {};
// Use the specific function you already defined
await showRosterChirpNotification({
title: n.title || d.title || 'New Message',
body: n.body || d.body || '',
url: d.url || d.link || '/', // some SDKs use 'link'
groupId: d.groupId || '',
});
} else {
// Fallback if we woke up for a "ghost" push with no data
await self.registration.showNotification('RosterChirp', {
body: 'You have a new update.',
tag: 'rosterchirp-fallback'
});
}
} catch (error) {
console.error('[SW] Critical Push Error:', error);
}
})()
);
});
Why this fixes your issues:
Eliminates the Race Condition: By wrapping the logic in an async function inside event.waitUntil, you tell Android/iOS: "Do not kill this process until the notification is actually rendered on the screen."
Bypasses SDK "Sleep": Sometimes the Firebase SDK takes too long to initialize. This code tries to parse the event.data directly first. Direct parsing is almost instantaneous, which is critical when a phone is in a deep sleep state.
Handles "Ghost" Pushes: Sometimes FCM sends a "heartbeat" or an empty push to wake the device. This script ensures that even if data is missing, the worker stays alive long enough to handle it gracefully rather than crashing.
One Final Server-Side Check
Since you are using HAProxy, check your server logs for the HTTP status code when you send a message to a mobile device.
If you see 200 OK but no notification: The issue was definitely this Service Worker "sleep" issue.
If you see 401 or 403: HAProxy might be stripping the Authorization header from your backend's outbound request to Google.
If you see 400: Ensure your backend is sending priority: "high" in the FCM JSON.
Would you like me to provide a Python or Node.js snippet to test sending a "High Priority" message with the correct v1 API headers?

View File

@@ -75,58 +75,47 @@ function showRosterChirpNotification(data) {
});
}
// ── FCM background messages ───────────────────────────────────────────────────
// Server sends notification+data messages (mirrors the working fcm-app pattern).
// payload.notification carries title/body; payload.data carries url/groupId.
// Fallback to payload.data.title/body supports any older data-only messages still
// in-flight during a deployment transition.
if (messaging) {
messaging.onBackgroundMessage((payload) => {
console.log('[SW] onBackgroundMessage received:', JSON.stringify({ notification: payload.notification, data: payload.data }));
// ── Push handler ──────────────────────────────────────────────────────────────
// Unified handler — always uses event.waitUntil so the mobile OS does not
// terminate the SW before the notification is shown. Parses event.data
// directly (fast, reliable) rather than delegating to the Firebase SDK's
// internal push listener, which can be killed before it finishes on Android.
self.addEventListener('push', (event) => {
console.log('[SW] Push received, hasData:', !!event.data, 'messaging:', !!messaging);
event.waitUntil((async () => {
try {
let payload = null;
if (event.data) {
try {
payload = event.data.json();
console.log('[SW] Push data:', JSON.stringify({ notification: payload.notification, data: payload.data }));
} catch (e) {
console.warn('[SW] Push data not JSON:', e);
}
}
if (payload) {
const n = payload.notification || {};
const d = payload.data || {};
return showRosterChirpNotification({
await showRosterChirpNotification({
title: n.title || d.title || 'New Message',
body: n.body || d.body || '',
url: d.url || '/',
groupId: d.groupId || '',
});
});
} else {
console.warn('[SW] Firebase messaging not initialised — push notifications disabled');
}
// ── Raw push event (fallback for Android) ─────────────────────────────────────
// Only runs when Firebase messaging is NOT yet initialised (e.g. push arrives
// before firebaseConfigPromise resolves). If Firebase IS ready it already has
// its own push listener registered and will call onBackgroundMessage — running
// this handler too would show the notification twice.
self.addEventListener('push', (event) => {
console.log('[SW] push event received, hasData:', !!event.data, 'messaging ready:', !!messaging);
// Firebase compat SDK handles it via onBackgroundMessage — skip to avoid double notification.
if (messaging) return;
if (!event.data) return;
event.waitUntil((async () => {
try {
const data = event.data.json();
console.log('[SW] Push fallback — data parsed:', JSON.stringify(data));
if (data.data || (data.title && data.body)) {
await showRosterChirpNotification(data.data || data);
}
} catch (e) {
console.warn('[SW] Push fallback — failed to parse push data:', e);
const text = event.data.text();
if (text) {
} else {
// Ghost push — keep SW alive and show a generic notification
await self.registration.showNotification('RosterChirp', {
body: text.slice(0, 100),
body: 'You have a new message.',
icon: '/icons/icon-192.png',
badge: '/icons/icon-192-maskable.png',
tag: 'rosterchirp-fallback',
});
}
} catch (e) {
console.error('[SW] Push handler error:', e);
}
})());
});

77
sw.js.gemini.txt Normal file
View File

@@ -0,0 +1,77 @@
Gemini said
Ive reviewed your Service Worker code. Its well-structured, but there is a critical architectural "race condition" occurring here that is likely causing the "hit or miss" behavior on mobile.
On a desktop, the browser keeps the Service Worker "warm" or gives it plenty of time to boot. On mobile (especially Android), the OS is much more aggressive.
1. The onBackgroundMessage vs. event.waitUntil Problem
In your Firebase Messaging block:
JavaScript
messaging.onBackgroundMessage((payload) => {
// ... logic ...
return showRosterChirpNotification({...});
});
The Flaw: The Firebase onBackgroundMessage handler is a callback, but it is not being awaited by the Service Worker's underlying push event.
When a push arrives, the browser fires a native push event. The Firebase SDK intercepts this and triggers your onBackgroundMessage. However, because you aren't explicitly telling the browser to "stay awake" while Firebase does its thing, the mobile OS often kills the Service Worker thread before the Firebase SDK even finishes parsing the message or before showRosterChirpNotification completes.
2. The "Double Listener" Conflict
You have both messaging.onBackgroundMessage and a manual self.addEventListener('push').
You added a check if (messaging) return; inside the push listener to prevent double-firing.
The Catch: In 2026, the firebase-messaging-compat SDK actually needs that native push event to stay alive. By returning early, you might be accidentally telling the browser "I'm done here," allowing it to terminate the process before the Firebase logic finishes.
The Fix: "Promisifying" the Background Handler
To fix the reliability on mobile, you need to ensure the native push event wraps the Firebase logic in a Promise.
Modify your code to look like this:
JavaScript
// 1. Remove the return showRosterChirpNotification from onBackgroundMessage
// 2. Wrap the logic in the native 'push' event to guarantee execution.
self.addEventListener('push', (event) => {
console.log('[SW] Push received');
// We use event.waitUntil to tell the Mobile OS: "Don't kill me yet!"
event.waitUntil(
// If Firebase is ready, we let it process, but we must make sure
// we return a promise that resolves only AFTER the notification shows.
Promise.resolve().then(async () => {
try {
// You can still use Firebase logic, but ensure it's awaited.
// Or, for maximum mobile reliability, parse the event.data directly:
const payload = event.data ? event.data.json() : null;
if (payload) {
// Manually handle the notification display here to ensure
// the 'waitUntil' scope covers it.
return showRosterChirpNotification({
title: payload.notification?.title || payload.data?.title || 'New Message',
body: payload.notification?.body || payload.data?.body || '',
url: payload.data?.url || '/',
groupId: payload.data?.groupId || ''
});
}
} catch (err) {
console.error('[SW] Push processing failed', err);
}
})
);
});
3. Why Desktop works but Mobile fails
Desktop: The browser process is persistent. It waits for the Firebase callback to finish even if you don't use event.waitUntil.
Mobile: The moment the push event function finishes executing (which happens instantly if it's just a bunch of if statements and callbacks), the OS is allowed to freeze the thread.
4. HAProxy / PFSense Check (The "Vary" Header)
Since you are using importScripts for Firebase:
If HAProxy is caching your sw.js but serving it with a content-type or encoding that makes the mobile browser hesitate, it will fail to update. Ensure HAProxy isn't stripping the Service-Worker-Allowed header if you've set one, and make sure your server sends Cache-Control: no-cache for the sw.js file specifically.
Summary of adjustments:
Remove the if (messaging) return; from your push listener.
Consolidate your logic inside the push listener using event.waitUntil().
Check Firebase Console: Ensure the messages you send from your server are marked as High Priority (priority: 'high'), otherwise mobile devices will "batch" them and they'll arrive 10-20 minutes late.