feat: Add PWA push notifications for room events

- Add service worker (sw.js) for push notification handling
- Add usePushNotifications hook for subscription management
- Add NotificationToggle component in room header
- Update sync server with web-push for sending notifications
- Add VAPID keys configuration
- Notifications for: friend joins, friend leaves, meeting points set

Push notification events:
- Friend joins room: "Friend Joined! 👋"
- Friend leaves room: "Friend Left"
- Meeting point set: "Meeting Point Set! 📍"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2025-12-29 01:13:16 +01:00
parent 8eed0deadf
commit dcb6657966
10 changed files with 888 additions and 4 deletions

View File

@ -12,5 +12,10 @@ C3NAV_BASE_URL=https://39c3.c3nav.de
# For production: wss://sync.rmaps.online
NEXT_PUBLIC_SYNC_URL=wss://sync.rmaps.online
# Push Notifications (VAPID keys)
# Generate with: cd sync-server && npx web-push generate-vapid-keys
# The public key must be shared with the client (NEXT_PUBLIC_)
NEXT_PUBLIC_VAPID_PUBLIC_KEY=BNWACJudUOeHEZKEFB-0Wz086nHYsWzj12LqQ7lsUNT38ThtNUoZTJYEH9lttQitCROE2G3Ob71ZUww47yvCDbk
# Analytics (optional)
# NEXT_PUBLIC_PLAUSIBLE_DOMAIN=rmaps.online

114
public/sw.js Normal file
View File

@ -0,0 +1,114 @@
// rMaps Service Worker for Push Notifications
const CACHE_NAME = 'rmaps-v1';
// Install event - cache essential assets
self.addEventListener('install', (event) => {
console.log('[SW] Installing service worker...');
self.skipWaiting();
});
// Activate event - clean up old caches
self.addEventListener('activate', (event) => {
console.log('[SW] Activating service worker...');
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
);
})
);
self.clients.claim();
});
// Push event - handle incoming push notifications
self.addEventListener('push', (event) => {
console.log('[SW] Push received:', event);
let data = {
title: 'rMaps',
body: 'You have a new notification',
icon: '/icon-192.png',
badge: '/icon-192.png',
tag: 'rmaps-notification',
data: {},
};
if (event.data) {
try {
const payload = event.data.json();
data = { ...data, ...payload };
} catch (e) {
data.body = event.data.text();
}
}
const options = {
body: data.body,
icon: data.icon || '/icon-192.png',
badge: data.badge || '/icon-192.png',
tag: data.tag || 'rmaps-notification',
data: data.data || {},
vibrate: [100, 50, 100],
actions: data.actions || [],
requireInteraction: data.requireInteraction || false,
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
});
// Notification click event - handle user interaction
self.addEventListener('notificationclick', (event) => {
console.log('[SW] Notification clicked:', event);
event.notification.close();
const data = event.notification.data || {};
let targetUrl = '/';
// Determine URL based on notification type
if (data.roomSlug) {
targetUrl = `/${data.roomSlug}`;
} else if (data.url) {
targetUrl = data.url;
}
// Handle action buttons
if (event.action === 'view') {
targetUrl = data.url || targetUrl;
} else if (event.action === 'dismiss') {
return;
}
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
// Try to focus an existing window
for (const client of clientList) {
if (client.url.includes(targetUrl) && 'focus' in client) {
return client.focus();
}
}
// Open a new window if none exists
if (clients.openWindow) {
return clients.openWindow(targetUrl);
}
})
);
});
// Handle notification close
self.addEventListener('notificationclose', (event) => {
console.log('[SW] Notification closed:', event);
});
// Handle messages from the main thread
self.addEventListener('message', (event) => {
console.log('[SW] Message received:', event.data);
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});

View File

@ -258,6 +258,7 @@ export default function RoomPage() {
onToggleSharing={handleToggleSharing}
onShare={() => setShowShare(true)}
onToggleParticipants={() => setShowParticipants(!showParticipants)}
syncUrl={process.env.NEXT_PUBLIC_SYNC_URL}
/>
{/* Main Content */}

View File

@ -0,0 +1,95 @@
'use client';
import { useState } from 'react';
import { usePushNotifications } from '@/hooks/usePushNotifications';
interface NotificationToggleProps {
roomSlug: string;
syncUrl?: string;
}
export default function NotificationToggle({ roomSlug, syncUrl }: NotificationToggleProps) {
const {
isSupported,
isSubscribed,
isLoading,
permission,
error,
toggleSubscription,
} = usePushNotifications({ roomSlug, syncUrl });
const [showTooltip, setShowTooltip] = useState(false);
if (!isSupported) {
return null; // Don't show if not supported
}
const handleClick = async () => {
try {
await toggleSubscription();
} catch (err) {
console.error('Failed to toggle notifications:', err);
}
};
const getStatusText = () => {
if (isLoading) return 'Loading...';
if (error) return error;
if (permission === 'denied') return 'Notifications blocked';
if (isSubscribed) return 'Notifications on';
return 'Enable notifications';
};
const getIcon = () => {
if (isSubscribed) {
return (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2zm-2 1H8v-6c0-2.48 1.51-4.5 4-4.5s4 2.02 4 4.5v6z" />
</svg>
);
}
return (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
);
};
return (
<div className="relative">
<button
onClick={handleClick}
disabled={isLoading || permission === 'denied'}
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
className={`p-2 rounded-lg transition-colors ${
isSubscribed
? 'bg-rmaps-primary text-white'
: 'bg-white/10 text-white/60 hover:bg-white/20 hover:text-white'
} ${isLoading ? 'opacity-50 cursor-wait' : ''} ${
permission === 'denied' ? 'opacity-30 cursor-not-allowed' : ''
}`}
title={getStatusText()}
>
{isLoading ? (
<div className="w-5 h-5 border-2 border-current border-t-transparent rounded-full animate-spin" />
) : (
getIcon()
)}
</button>
{/* Tooltip */}
{showTooltip && (
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-black/90 text-white text-xs rounded whitespace-nowrap z-50">
{getStatusText()}
<div className="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-black/90" />
</div>
)}
</div>
);
}

View File

@ -1,5 +1,7 @@
'use client';
import NotificationToggle from './NotificationToggle';
interface RoomHeaderProps {
roomSlug: string;
participantCount: number;
@ -7,6 +9,7 @@ interface RoomHeaderProps {
onToggleSharing: () => void;
onShare: () => void;
onToggleParticipants: () => void;
syncUrl?: string;
}
export default function RoomHeader({
@ -16,6 +19,7 @@ export default function RoomHeader({
onToggleSharing,
onShare,
onToggleParticipants,
syncUrl,
}: RoomHeaderProps) {
return (
<header className="bg-rmaps-dark/95 backdrop-blur border-b border-white/10 px-4 py-3 flex items-center justify-between z-10">
@ -39,6 +43,9 @@ export default function RoomHeader({
{/* Right: Actions */}
<div className="flex items-center gap-2">
{/* Notification toggle */}
<NotificationToggle roomSlug={roomSlug} syncUrl={syncUrl} />
{/* Share button */}
<button
onClick={onShare}

View File

@ -0,0 +1,268 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
interface PushSubscriptionState {
isSupported: boolean;
isSubscribed: boolean;
isLoading: boolean;
permission: NotificationPermission | 'default';
error: string | null;
}
interface UsePushNotificationsOptions {
/** Room slug to associate subscription with */
roomSlug?: string;
/** Sync server URL for storing subscriptions */
syncUrl?: string;
}
// VAPID public key - should match the server's public key
// Generate with: npx web-push generate-vapid-keys
const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || '';
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
export function usePushNotifications(options: UsePushNotificationsOptions = {}) {
const { roomSlug, syncUrl } = options;
const [state, setState] = useState<PushSubscriptionState>({
isSupported: false,
isSubscribed: false,
isLoading: true,
permission: 'default',
error: null,
});
const [subscription, setSubscription] = useState<PushSubscription | null>(null);
// Check if push notifications are supported
useEffect(() => {
const checkSupport = async () => {
const isSupported =
'serviceWorker' in navigator &&
'PushManager' in window &&
'Notification' in window;
if (!isSupported) {
setState((prev) => ({
...prev,
isSupported: false,
isLoading: false,
error: 'Push notifications are not supported in this browser',
}));
return;
}
// Check current permission
const permission = Notification.permission;
setState((prev) => ({
...prev,
isSupported: true,
permission,
isLoading: true,
}));
// Register service worker and check subscription
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
});
console.log('Service worker registered:', registration);
// Wait for the service worker to be ready
await navigator.serviceWorker.ready;
// Check existing subscription
const existingSubscription = await registration.pushManager.getSubscription();
if (existingSubscription) {
setSubscription(existingSubscription);
setState((prev) => ({
...prev,
isSubscribed: true,
isLoading: false,
}));
} else {
setState((prev) => ({
...prev,
isSubscribed: false,
isLoading: false,
}));
}
} catch (error) {
console.error('Service worker registration failed:', error);
setState((prev) => ({
...prev,
isLoading: false,
error: 'Failed to register service worker',
}));
}
};
checkSupport();
}, []);
// Subscribe to push notifications
const subscribe = useCallback(async () => {
if (!state.isSupported) {
throw new Error('Push notifications not supported');
}
setState((prev) => ({ ...prev, isLoading: true, error: null }));
try {
// Request notification permission
const permission = await Notification.requestPermission();
setState((prev) => ({ ...prev, permission }));
if (permission !== 'granted') {
throw new Error('Notification permission denied');
}
// Get service worker registration
const registration = await navigator.serviceWorker.ready;
// Check if VAPID key is configured
if (!VAPID_PUBLIC_KEY) {
throw new Error('VAPID public key not configured');
}
// Subscribe to push notifications
const pushSubscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
console.log('Push subscription created:', pushSubscription);
// Send subscription to server
if (syncUrl) {
const response = await fetch(`${syncUrl.replace('wss://', 'https://').replace('ws://', 'http://')}/push/subscribe`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
subscription: pushSubscription.toJSON(),
roomSlug,
}),
});
if (!response.ok) {
console.warn('Failed to save subscription to server');
}
}
// Also save to localStorage for persistence
localStorage.setItem('rmaps_push_subscription', JSON.stringify(pushSubscription.toJSON()));
if (roomSlug) {
localStorage.setItem(`rmaps_push_room_${roomSlug}`, 'true');
}
setSubscription(pushSubscription);
setState((prev) => ({
...prev,
isSubscribed: true,
isLoading: false,
}));
return pushSubscription;
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to subscribe';
console.error('Push subscription failed:', error);
setState((prev) => ({
...prev,
isLoading: false,
error: message,
}));
throw error;
}
}, [state.isSupported, syncUrl, roomSlug]);
// Unsubscribe from push notifications
const unsubscribe = useCallback(async () => {
if (!subscription) {
return;
}
setState((prev) => ({ ...prev, isLoading: true, error: null }));
try {
// Unsubscribe from push manager
await subscription.unsubscribe();
// Remove from server
if (syncUrl) {
try {
await fetch(`${syncUrl.replace('wss://', 'https://').replace('ws://', 'http://')}/push/unsubscribe`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
endpoint: subscription.endpoint,
roomSlug,
}),
});
} catch {
// Ignore server errors on unsubscribe
}
}
// Remove from localStorage
localStorage.removeItem('rmaps_push_subscription');
if (roomSlug) {
localStorage.removeItem(`rmaps_push_room_${roomSlug}`);
}
setSubscription(null);
setState((prev) => ({
...prev,
isSubscribed: false,
isLoading: false,
}));
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to unsubscribe';
console.error('Push unsubscribe failed:', error);
setState((prev) => ({
...prev,
isLoading: false,
error: message,
}));
throw error;
}
}, [subscription, syncUrl, roomSlug]);
// Toggle subscription
const toggleSubscription = useCallback(async () => {
if (state.isSubscribed) {
await unsubscribe();
} else {
await subscribe();
}
}, [state.isSubscribed, subscribe, unsubscribe]);
return {
...state,
subscription,
subscribe,
unsubscribe,
toggleSubscription,
};
}

View File

@ -5,6 +5,10 @@ services:
restart: unless-stopped
environment:
- PORT=3001
# VAPID keys for push notifications
- VAPID_PUBLIC_KEY=BNWACJudUOeHEZKEFB-0Wz086nHYsWzj12LqQ7lsUNT38ThtNUoZTJYEH9lttQitCROE2G3Ob71ZUww47yvCDbk
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY:-x3yCse1Q4rbZ1XLgnJ1KpSuRlw2ccHDW0fMcKtQ1qcw}
- VAPID_SUBJECT=mailto:push@rmaps.online
labels:
- "traefik.enable=true"
# HTTP router (redirects to HTTPS via Cloudflare)

View File

@ -8,9 +8,184 @@
"name": "rmaps-sync-server",
"version": "1.0.0",
"dependencies": {
"web-push": "^3.6.7",
"ws": "^8.18.0"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/bn.js": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
"license": "MIT"
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"license": "MPL-2.0",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",

View File

@ -6,9 +6,11 @@
"type": "module",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
"dev": "node --watch server.js",
"generate-vapid": "node -e \"const wp = require('web-push'); const keys = wp.generateVAPIDKeys(); console.log('VAPID_PUBLIC_KEY=' + keys.publicKey); console.log('VAPID_PRIVATE_KEY=' + keys.privateKey);\""
},
"dependencies": {
"web-push": "^3.6.7",
"ws": "^8.18.0"
}
}

View File

@ -2,16 +2,34 @@ import { WebSocketServer } from 'ws';
import { createServer } from 'http';
import { parse } from 'url';
import { randomUUID } from 'crypto';
import webpush from 'web-push';
const PORT = process.env.PORT || 3001;
const STALE_THRESHOLD_MS = 60 * 60 * 1000; // 1 hour
// VAPID keys for push notifications
// Generate with: npx web-push generate-vapid-keys
const VAPID_PUBLIC_KEY = process.env.VAPID_PUBLIC_KEY || '';
const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY || '';
const VAPID_SUBJECT = process.env.VAPID_SUBJECT || 'mailto:push@rmaps.online';
// Configure web-push if keys are available
if (VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY) {
webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY);
console.log('Push notifications enabled');
} else {
console.log('Push notifications disabled (VAPID keys not configured)');
}
// Room state storage: Map<roomSlug, RoomState>
const rooms = new Map();
// Client tracking: Map<WebSocket, { roomSlug, participantId }>
const clients = new Map();
// Push subscriptions: Map<roomSlug, Set<subscription>>
const pushSubscriptions = new Map();
function getRoomState(slug) {
if (!rooms.has(slug)) {
rooms.set(slug, {
@ -20,7 +38,7 @@ function getRoomState(slug) {
name: slug,
createdAt: new Date().toISOString(),
participants: {},
waypoints: [], // Array to match client expectation
waypoints: [],
lastActivity: Date.now()
});
}
@ -61,6 +79,42 @@ function broadcast(roomSlug, message, excludeWs = null) {
return count;
}
// Send push notification to all subscriptions for a room
async function sendPushToRoom(roomSlug, notification, excludeEndpoint = null) {
if (!VAPID_PUBLIC_KEY || !VAPID_PRIVATE_KEY) return;
const subs = pushSubscriptions.get(roomSlug);
if (!subs || subs.size === 0) return;
const payload = JSON.stringify(notification);
const failedEndpoints = [];
for (const sub of subs) {
if (excludeEndpoint && sub.endpoint === excludeEndpoint) continue;
try {
await webpush.sendNotification(sub, payload);
console.log(`[${roomSlug}] Push sent to ${sub.endpoint.slice(-20)}`);
} catch (error) {
console.error(`[${roomSlug}] Push failed:`, error.statusCode || error.message);
// Remove invalid subscriptions
if (error.statusCode === 404 || error.statusCode === 410) {
failedEndpoints.push(sub.endpoint);
}
}
}
// Clean up failed subscriptions
for (const endpoint of failedEndpoints) {
for (const sub of subs) {
if (sub.endpoint === endpoint) {
subs.delete(sub);
console.log(`[${roomSlug}] Removed invalid subscription`);
}
}
}
}
function handleMessage(ws, data) {
const clientInfo = clients.get(ws);
if (!clientInfo) return;
@ -89,6 +143,19 @@ function handleMessage(ws, data) {
// Broadcast join to others
broadcast(clientInfo.roomSlug, message, ws);
// Send push notification to others
sendPushToRoom(clientInfo.roomSlug, {
title: 'Friend Joined! 👋',
body: `${participant.name} ${participant.emoji} joined the room`,
tag: `join-${participant.id}`,
data: {
type: 'join',
roomSlug: clientInfo.roomSlug,
participantId: participant.id,
participantName: participant.name,
},
});
// Send current state to the new participant
ws.send(JSON.stringify({
type: 'full_state',
@ -98,9 +165,24 @@ function handleMessage(ws, data) {
}
case 'leave': {
const leavingParticipant = room.participants[message.participantId];
delete room.participants[message.participantId];
console.log(`[${clientInfo.roomSlug}] Participant left: ${message.participantId}`);
broadcast(clientInfo.roomSlug, message, ws);
// Send push notification
if (leavingParticipant) {
sendPushToRoom(clientInfo.roomSlug, {
title: 'Friend Left',
body: `${leavingParticipant.name} ${leavingParticipant.emoji} left the room`,
tag: `leave-${message.participantId}`,
data: {
type: 'leave',
roomSlug: clientInfo.roomSlug,
participantId: message.participantId,
},
});
}
break;
}
@ -129,6 +211,27 @@ function handleMessage(ws, data) {
room.waypoints.push(message.waypoint);
console.log(`[${clientInfo.roomSlug}] Waypoint added: ${message.waypoint.id}`);
broadcast(clientInfo.roomSlug, message, ws);
// Send push notification for meeting points
if (message.waypoint.type === 'meeting_point') {
const creator = room.participants[clientInfo.participantId];
sendPushToRoom(clientInfo.roomSlug, {
title: 'Meeting Point Set! 📍',
body: `${creator?.name || 'Someone'} set a meeting point: ${message.waypoint.name}`,
tag: `waypoint-${message.waypoint.id}`,
requireInteraction: true,
data: {
type: 'waypoint',
roomSlug: clientInfo.roomSlug,
waypointId: message.waypoint.id,
waypointName: message.waypoint.name,
},
actions: [
{ action: 'view', title: 'View on Map' },
{ action: 'dismiss', title: 'Dismiss' },
],
});
}
break;
}
@ -183,14 +286,46 @@ setInterval(() => {
if (Object.keys(room.participants).length === 0 &&
now - room.lastActivity > 24 * 60 * 60 * 1000) {
rooms.delete(slug);
pushSubscriptions.delete(slug);
console.log(`Cleaned up empty room: ${slug}`);
}
}
}, 5 * 60 * 1000); // Every 5 minutes
// Create HTTP server for health checks
const server = createServer((req, res) => {
// Parse JSON body from request
async function parseJsonBody(req) {
return new Promise((resolve, reject) => {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
try {
resolve(JSON.parse(body));
} catch (e) {
reject(new Error('Invalid JSON'));
}
});
req.on('error', reject);
});
}
// Add CORS headers
function addCorsHeaders(res) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
}
// Create HTTP server for health checks and push subscription endpoints
const server = createServer(async (req, res) => {
const { pathname } = parse(req.url);
addCorsHeaders(res);
// Handle CORS preflight
if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return;
}
if (pathname === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
@ -198,6 +333,7 @@ const server = createServer((req, res) => {
status: 'ok',
rooms: rooms.size,
clients: clients.size,
pushEnabled: !!(VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY),
uptime: process.uptime()
}));
} else if (pathname === '/stats') {
@ -206,11 +342,87 @@ const server = createServer((req, res) => {
roomStats[slug] = {
participants: Object.keys(room.participants).length,
waypoints: room.waypoints.length,
pushSubscriptions: pushSubscriptions.get(slug)?.size || 0,
lastActivity: room.lastActivity
};
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ rooms: roomStats, totalClients: clients.size }));
} else if (pathname === '/push/vapid-public-key') {
// Return VAPID public key for client
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ publicKey: VAPID_PUBLIC_KEY }));
} else if (pathname === '/push/subscribe' && req.method === 'POST') {
// Subscribe to push notifications
try {
const { subscription, roomSlug } = await parseJsonBody(req);
if (!subscription || !subscription.endpoint) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid subscription' }));
return;
}
// Store subscription for the room
if (roomSlug) {
if (!pushSubscriptions.has(roomSlug)) {
pushSubscriptions.set(roomSlug, new Set());
}
pushSubscriptions.get(roomSlug).add(subscription);
console.log(`[${roomSlug}] Push subscription added`);
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true }));
} catch (error) {
console.error('Push subscribe error:', error);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to subscribe' }));
}
} else if (pathname === '/push/unsubscribe' && req.method === 'POST') {
// Unsubscribe from push notifications
try {
const { endpoint, roomSlug } = await parseJsonBody(req);
if (roomSlug && pushSubscriptions.has(roomSlug)) {
const subs = pushSubscriptions.get(roomSlug);
for (const sub of subs) {
if (sub.endpoint === endpoint) {
subs.delete(sub);
console.log(`[${roomSlug}] Push subscription removed`);
break;
}
}
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true }));
} catch (error) {
console.error('Push unsubscribe error:', error);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to unsubscribe' }));
}
} else if (pathname === '/push/test' && req.method === 'POST') {
// Send test push notification
try {
const { roomSlug } = await parseJsonBody(req);
if (roomSlug) {
await sendPushToRoom(roomSlug, {
title: 'Test Notification 🔔',
body: 'Push notifications are working!',
tag: 'test',
data: { type: 'test', roomSlug },
});
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true }));
} catch (error) {
console.error('Push test error:', error);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to send test' }));
}
} else {
res.writeHead(404);
res.end('Not found');
@ -261,4 +473,5 @@ server.listen(PORT, () => {
console.log(`rmaps sync server listening on port ${PORT}`);
console.log(`WebSocket: ws://localhost:${PORT}/room/{slug}`);
console.log(`Health check: http://localhost:${PORT}/health`);
console.log(`Push API: http://localhost:${PORT}/push/subscribe`);
});