word-waltz / files / functions / index.js
index.js
51cb47e7 2026-03-26 Pavel Safronov | 1 | /** |
| 2 | * Word Waltz — Firebase Cloud Functions | |
| 3 | * | |
| 4 | * sendPushOnTurnChange: Triggered on every Firestore write to a game document. | |
| 5 | * If pendingNotification is set, it sends a Web Push message to the indicated | |
| 6 | * player's subscription, then clears pendingNotification. | |
| 7 | * | |
| 8 | * Setup (one-time): | |
| 9 | * 1. Install the Firebase CLI: npm install -g firebase-tools | |
| 10 | * 2. Upgrade to the Blaze (pay-as-you-go) plan — required for Cloud Functions. | |
| 11 | * 3. Generate VAPID keys: | |
| 12 | * npx web-push generate-vapid-keys | |
| 13 | * 4. Store secrets in Firebase: | |
| 14 | * firebase functions:secrets:set VAPID_SUBJECT # e.g. mailto:you@example.com | |
| 15 | * firebase functions:secrets:set VAPID_PUBLIC_KEY | |
| 16 | * firebase functions:secrets:set VAPID_PRIVATE_KEY | |
| 17 | * 5. Set VITE_VAPID_PUBLIC_KEY (the same public key) in your .env and as a | |
| 18 | * GitLab CI/CD variable so the client can subscribe to push. | |
| 19 | * 6. Deploy: | |
| 20 | * cd functions && npm install | |
| 21 | * firebase deploy --only functions | |
| 22 | */ | |
| 23 | ||
| 24 | /* eslint-disable @typescript-eslint/no-var-requires */ | |
| 25 | const { onDocumentWritten } = require('firebase-functions/v2/firestore'); | |
| 26 | const { defineSecret } = require('firebase-functions/params'); | |
| 27 | const admin = require('firebase-admin'); | |
| 28 | const webpush = require('web-push'); | |
| 29 | ||
| 30 | admin.initializeApp(); | |
| 31 | ||
| 32 | const VAPID_SUBJECT = defineSecret('VAPID_SUBJECT'); | |
| 33 | const VAPID_PUBLIC_KEY = defineSecret('VAPID_PUBLIC_KEY'); | |
| 34 | const VAPID_PRIVATE_KEY = defineSecret('VAPID_PRIVATE_KEY'); | |
| 35 | ||
| 36 | exports.sendPushOnTurnChange = onDocumentWritten( | |
| 37 | { | |
| 38 | document: 'games/{gameId}', | |
| 39 | secrets: [VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY], | |
| 40 | }, | |
| 41 | async (event) => { | |
| 42 | const after = event.data?.after; | |
| 43 | if (!after?.exists) return; | |
| 44 | ||
| 45 | const data = after.data(); | |
| 46 | ||
| 47 | // Nothing to do if there's no pending notification. | |
| 48 | const notification = data.pendingNotification; | |
| 49 | if (!notification || !notification.player || !notification.message) return; | |
| 50 | ||
| 51 | const player = notification.player; // 'a' or 'b' | |
| 52 | const subJson = data.pushSubs?.[player]; | |
| 53 | if (!subJson) return; | |
| 54 | ||
| 55 | let subscription; | |
| 56 | try { | |
| 57 | subscription = JSON.parse(subJson); | |
| 58 | } catch { | |
| 59 | console.warn('Malformed push subscription JSON for player', player); | |
| 60 | return; | |
| 61 | } | |
| 62 | ||
| 63 | webpush.setVapidDetails( | |
| 64 | VAPID_SUBJECT.value(), | |
| 65 | VAPID_PUBLIC_KEY.value(), | |
| 66 | VAPID_PRIVATE_KEY.value(), | |
| 67 | ); | |
| 68 | ||
| 69 | const payload = JSON.stringify({ | |
| 70 | title: 'Word Waltz', | |
| 71 | body: notification.message, | |
| 72 | url: notification.gameUrl ?? '', // home screen URL (no key) | |
| 73 | }); | |
| 74 | ||
| 75 | try { | |
| 76 | await webpush.sendNotification(subscription, payload); | |
| 77 | } catch (err) { | |
| 78 | // 410 Gone = subscription expired; clear it so we don't retry. | |
| 79 | if (err?.statusCode === 410) { | |
| 80 | await after.ref.update({ [`pushSubs.${player}`]: null }); | |
| 81 | } | |
| 82 | console.error('Push send failed:', err?.statusCode, err?.body); | |
| 83 | } | |
| 84 | ||
| 85 | // Clear the pendingNotification so this function is not re-triggered. | |
| 86 | await after.ref.update({ pendingNotification: null }); | |
| 87 | }, | |
| 88 | ); |