fuzzyworld

word-waltz / files / functions / index.js

index.js

Blame: the commit that last changed each line · file · history

51cb47e7 2026-03-26 Pavel Safronov1/**
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 */
25const { onDocumentWritten } = require('firebase-functions/v2/firestore');
26const { defineSecret } = require('firebase-functions/params');
27const admin = require('firebase-admin');
28const webpush = require('web-push');
29
30admin.initializeApp();
31
32const VAPID_SUBJECT = defineSecret('VAPID_SUBJECT');
33const VAPID_PUBLIC_KEY = defineSecret('VAPID_PUBLIC_KEY');
34const VAPID_PRIVATE_KEY = defineSecret('VAPID_PRIVATE_KEY');
35
36exports.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);