v0.12.14 FCM optimization
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "rosterchirp-backend",
|
||||
"version": "0.12.13",
|
||||
"version": "0.12.14",
|
||||
"description": "RosterChirp backend server",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
2
build.sh
2
build.sh
@@ -13,7 +13,7 @@
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:-0.12.13}"
|
||||
VERSION="${1:-0.12.14}"
|
||||
ACTION="${2:-}"
|
||||
REGISTRY="${REGISTRY:-}"
|
||||
IMAGE_NAME="rosterchirp"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "rosterchirp-frontend",
|
||||
"version": "0.12.13",
|
||||
"version": "0.12.14",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
59
frontend/public/sw.gemini.js
Normal file
59
frontend/public/sw.gemini.js
Normal 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);
|
||||
}
|
||||
})()
|
||||
);
|
||||
});
|
||||
82
frontend/public/sw.gemini.js.txt
Normal file
82
frontend/public/sw.gemini.js.txt
Normal 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?
|
||||
@@ -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) {
|
||||
// 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
77
sw.js.gemini.txt
Normal file
@@ -0,0 +1,77 @@
|
||||
Gemini said
|
||||
I’ve reviewed your Service Worker code. It’s 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.
|
||||
Reference in New Issue
Block a user