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:
parent
8eed0deadf
commit
dcb6657966
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue