Files
rosterchirp/Reference/FCM_IMPLEMENTATION_NOTES.md

312 lines
12 KiB
Markdown

# 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:
```javascript
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)
```javascript
// 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)
```javascript
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.
```javascript
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.
```javascript
// 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.
```javascript
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:
```javascript
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.
```javascript
// 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**
```javascript
// 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.
```javascript
if (!token) {
throw new Error('getToken() returned empty — check VAPID key and service worker');
}
```
**Fix: Error message included server response**
```javascript
// 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.
```javascript
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.
```javascript
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.
```javascript
// 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`).
```javascript
// 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**
```javascript
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.
```javascript
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
```dockerfile
# 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
```yaml
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
```javascript
// 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