312 lines
12 KiB
Markdown
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
|