word-waltz / files / functions / index.js
index.js
| 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 | ); |