Files
rosterchirp-dev/FCM_IMPLEMENTATION_NOTES.md
2026-03-23 19:10:21 -04:00

12 KiB

FCM PWA Implementation Notes

Reference for applying FCM fixes to other projects


Part 1 — Guide Key Points (fcm_details.txt)

How FCM works (the correct flow)

  1. User grants notification permission
  2. Firebase generates a unique FCM token for the device
  3. Token is stored on your server for targeting
  4. Server sends push requests to Firebase
  5. Firebase delivers notifications to the device
  6. Service worker handles display and click interactions

Common vibe-coding failures with FCM

1. Service worker confusion Auto-generated setups often register multiple service workers or put Firebase logic in the wrong file. The dedicated firebase-messaging-sw.js must be served from root scope. Splitting logic across a redirect stub (importScripts('/sw.js')) causes background notifications to silently fail.

2. Deprecated API usage Using messaging.usePublicVapidKey() and messaging.useServiceWorker() instead of passing options directly to getToken(). The correct modern pattern is:

const token = await messaging.getToken({
  vapidKey: VAPID_KEY,
  serviceWorkerRegistration: registration
});

3. Token generation without durable storage Tokens disappear when users switch devices, clear storage, or the server restarts. Without a persistent store (file, database) and proper Docker volume mounts, tokens are lost on every restart.

4. Poor permission flow Requesting notification permission immediately on page load gets denied by users. Permission should be requested on a meaningful user action (e.g. login), not on first visit.

5. Missing notificationclick handler Without a notificationclick handler in the service worker, clicking a notification does nothing. Users expect it to open or focus the app.

6. Silent failures Tokens can be null, service workers can fail to register, VAPID keys can be wrong — and nothing surfaces in the UI. Every layer needs explicit error checking and user-visible feedback.

7. iOS blind spots iOS requires the PWA to be added to the home screen, strict HTTPS, and a correctly structured manifest. Test on real iOS devices, not just Chrome on Android/desktop.

Correct getToken() pattern (from guide)

// Register SW first, then pass it directly to getToken
const registration = await navigator.serviceWorker.register('/firebase-messaging-sw.js');
const token = await getToken(messaging, {
  vapidKey: VAPID_KEY,
  serviceWorkerRegistration: registration
});
if (!token) throw new Error('getToken() returned empty — check VAPID key and SW');

Correct firebase-messaging-sw.js pattern (from guide)

importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js');

firebase.initializeApp({ /* config */ });
const messaging = firebase.messaging();

messaging.onBackgroundMessage((payload) => {
  self.registration.showNotification(payload.notification.title, {
    body: payload.notification.body,
    icon: '/icon-192.png',
    badge: '/icon-192.png',
    tag: 'fcm-notification',
    data: payload.data
  });
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  if (event.action === 'close') return;
  event.waitUntil(
    clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
      for (const client of clientList) {
        if (client.url === '/' && 'focus' in client) return client.focus();
      }
      if (clients.openWindow) return clients.openWindow('/');
    })
  );
});

Part 2 — Code Fixes Applied to fcm-app

app.js fixes

Fix: showUserInfo() missing Function was called on login and session restore but never defined — crashed immediately on login.

function showUserInfo() {
  document.getElementById('loginForm').style.display = 'none';
  document.getElementById('userInfo').style.display = 'block';
  document.getElementById('currentUser').textContent = users[currentUser]?.name || currentUser;
}

Fix: setupApp() wrong element IDs getElementById('sendNotification') and getElementById('logoutBtn') returned null — no element with those IDs existed in the HTML.

// Wrong
document.getElementById('sendNotification').addEventListener('click', sendNotification);
// Fixed
document.getElementById('sendNotificationBtn').addEventListener('click', sendNotification);
// Also added id="logoutBtn" to the logout button in index.html

Fix: logout() not clearing localStorage Session was restored on next page load even after logout.

function logout() {
  currentUser = null;
  fcmToken = null;
  localStorage.removeItem('currentUser'); // was missing
  // ...
}

Fix: Race condition in messaging initialization initializeFirebase() was fire-and-forget. When called again from login(), it returned early setting messaging = firebase.messaging() without the VAPID key or SW being configured. Now returns and caches a promise:

let initPromise = null;
function initializeFirebase() {
  if (initPromise) return initPromise;
  initPromise = navigator.serviceWorker.register('/sw.js')
    .then((registration) => {
      swRegistration = registration;
      messaging = firebase.messaging();
    })
    .catch((error) => { initPromise = null; throw error; });
  return initPromise;
}
// In login():
await initializeFirebase(); // ensures messaging is ready before getToken()

Fix: deleteToken() invalidating tokens on every page load deleteToken() was called on every page load, invalidating the push subscription. The server still held the old (now invalid) token. When another device sent, the stale token failed and recipients stayed 0. Solution: removed deleteToken() entirely — it's not needed when serviceWorkerRegistration is passed directly to getToken().

Fix: Session restore without re-registering token When a user's session was restored from localStorage, showUserInfo() was called but no new FCM token was generated or sent to the server. After a server restart the server had no record of the token.

// In setupApp(), after restoring session:
if (Notification.permission === 'granted') {
  initializeFirebase()
    .then(() => messaging.getToken({ vapidKey: VAPID_KEY, serviceWorkerRegistration: swRegistration }))
    .then(token => { if (token) return registerToken(currentUser, token); })
    .catch(err => console.error('Token refresh on session restore failed:', err));
}

Fix: Deprecated VAPID/SW API replaced

// Removed (deprecated):
messaging.usePublicVapidKey(VAPID_KEY);
messaging.useServiceWorker(registration);
const token = await messaging.getToken();

// Replaced with:
const VAPID_KEY = 'your-vapid-key';
let swRegistration = null;
// swRegistration set inside initializeFirebase() .then()
const token = await messaging.getToken({ vapidKey: VAPID_KEY, serviceWorkerRegistration: swRegistration });

Fix: Null token guard getToken() can return null — passing null to the server produced a confusing 400 error.

if (!token) {
  throw new Error('getToken() returned empty — check VAPID key and service worker');
}

Fix: Error message included server response

// Before: throw new Error('Failed to register token');
// After:
throw new Error(`Server returned ${response.status}: ${errorText}`);

Fix: Duplicate foreground message handlers handleForegroundMessages() was called on every login, stacking up onMessage listeners.

let foregroundHandlerSetup = false;
function handleForegroundMessages() {
  if (foregroundHandlerSetup) return;
  foregroundHandlerSetup = true;
  messaging.onMessage(/* ... */);
}

Fix: login() event.preventDefault() crash Button called login() with no argument, so event.preventDefault() threw on undefined.

async function login(event) {
  if (event) event.preventDefault(); // guard added

Fix: firebase-messaging-sw.js redirect stub replaced File was importScripts('/sw.js') — a vibe-code anti-pattern. Replaced with full Firebase messaging setup including onBackgroundMessage and notificationclick handler (see Part 1 pattern above).

Fix: notificationclick handler added to sw.js Clicking a background notification did nothing. Handler added to focus existing window or open a new one.

Fix: CDN URLs removed from urlsToCache in sw.js External CDN URLs in cache.addAll() can fail on opaque responses, breaking the entire SW install.

// Removed from urlsToCache:
// 'https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js',
// 'https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js'

server.js fixes

Fix: icon/badge/tag in wrong notification object These fields are only valid in webpush.notification, not the top-level notification (which only accepts title, body, imageUrl).

// Wrong:
notification: { title, body, icon: '/icon-192.png', badge: '/icon-192.png', tag: 'fcm-test' }
// Fixed:
notification: { title, body },
webpush: {
  notification: { icon: '/icon-192.png', badge: '/icon-192.png', tag: 'fcm-test' },
  // ...
}

Fix: saveTokens() in route handler not crash-safe

try {
  saveTokens();
} catch (saveError) {
  console.error('Failed to persist tokens to disk:', saveError);
}

Fix: setInterval(saveTokens) uncaught exception crashed the server An unhandled throw inside setInterval exits the Node.js process. Docker restarts it with empty state.

setInterval(() => {
  try { saveTokens(); }
  catch (error) { console.error('Auto-save tokens failed:', error); }
}, 30000);

Part 3 — Docker / Infrastructure Fixes

Root cause of "no other users" bug

The server was crashing every ~30 seconds, wiping all registered tokens from memory. The crash chain:

  1. saveTokens() threw EACCES: permission denied (nodejs user can't write to root-owned /app)
  2. This propagated out of setInterval as an uncaught exception
  3. Node.js exited the process
  4. Docker restarted the container with empty state
  5. Tokens were never on disk, so restart = all tokens lost

Dockerfile fix

# Create non-root user AND a writable data directory (while still root)
RUN addgroup -g 1001 -S nodejs && \
    adduser -S nodejs -u 1001 && \
    mkdir -p /app/data && \
    chown nodejs:nodejs /app/data

WORKDIR /app is root-owned — the nodejs user can only write to subdirectories explicitly granted to it.

docker-compose.yml fix

services:
  your-app:
    volumes:
      - app_data:/app/data   # named volume survives container rebuilds

volumes:
  app_data:

Without this, tokens.json lives in the container's ephemeral layer and is deleted on every docker-compose up --build.

server.js path fix

// Changed from:
const TOKENS_FILE = './tokens.json';
// To:
const TOKENS_FILE = './data/tokens.json';

Checklist for applying to another project

  • firebase-messaging-sw.js contains real FCM logic (not a redirect stub)
  • notificationclick handler present in service worker
  • CDN URLs NOT in urlsToCache in any service worker
  • initializeFirebase() returns a promise; login awaits it before calling getToken()
  • getToken() receives { vapidKey, serviceWorkerRegistration } directly — no deprecated usePublicVapidKey / useServiceWorker
  • deleteToken() is NOT called on page load
  • Session restore re-registers FCM token if Notification.permission === 'granted'
  • Null/empty token check before sending to server
  • icon/badge/tag are in webpush.notification, not top-level notification
  • saveTokens() (or equivalent) wrapped in try-catch everywhere it's called including setInterval
  • Docker: data directory created with correct user ownership in Dockerfile
  • Docker: named volume mounted for data directory in docker-compose.yml
  • Duplicate foreground message handler registration is guarded