diff --git a/apps/web/package.json b/apps/web/package.json index 82badf04..cf68c77b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -19,6 +19,7 @@ "@anthropic-ai/sdk": "^0.128.0", "@profullstack/autoblog": "github:profullstack/autoblog#75e54af", "@profullstack/emailer": "^1.0.3", + "@profullstack/notifications": "0.1.0", "@profullstack/stack": "^0.1.3", "@profullstack/x402-gateway": "^0.7.0", "@serwist/next": "^9.5.0", @@ -38,7 +39,6 @@ "react-type-animation": "^3.2.0", "serwist": "^9.5.0", "tailwind-merge": "^2.6.0", - "web-push": "^3.6.7", "zod": "^3.24.0" }, "devDependencies": { @@ -51,7 +51,6 @@ "@types/node": "^22.10.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", - "@types/web-push": "^3.6.4", "autoprefixer": "^10.4.20", "jsdom": "^25.0.0", "postcss": "^8.4.49", diff --git a/apps/web/src/app/api/push/vapid-public-key/route.ts b/apps/web/src/app/api/push/vapid-public-key/route.ts new file mode 100644 index 00000000..3115eb90 --- /dev/null +++ b/apps/web/src/app/api/push/vapid-public-key/route.ts @@ -0,0 +1,15 @@ +import { vapidKeysFromEnv, vapidPublicKeyResponse } from '@profullstack/notifications/server'; + +/** + * GET /api/push/vapid-public-key — the VAPID public key, served at runtime. + * + * The browser used to get it from NEXT_PUBLIC_VAPID_PUBLIC_KEY compiled into + * the bundle; when the Docker build did not have it, every browser was told + * "Push notifications are not supported". Fetching it from here cannot drift + * from the key the server signs with. + */ +export const dynamic = 'force-dynamic'; + +export function GET() { + return vapidPublicKeyResponse(vapidKeysFromEnv(process.env)); +} diff --git a/apps/web/src/components/notifications/NotificationPreferences.test.tsx b/apps/web/src/components/notifications/NotificationPreferences.test.tsx index eb82de27..ceaeed8e 100644 --- a/apps/web/src/components/notifications/NotificationPreferences.test.tsx +++ b/apps/web/src/components/notifications/NotificationPreferences.test.tsx @@ -9,6 +9,7 @@ const mockUnsubscribe = vi.fn().mockResolvedValue(true); vi.mock('@/hooks/usePushNotifications', () => ({ usePushNotifications: vi.fn(() => ({ isSupported: true, + unsupportedReason: null, permission: 'default' as NotificationPermission, isSubscribed: false, isLoading: false, @@ -31,6 +32,7 @@ describe('NotificationPreferences', () => { it('should show unsupported message when push is not available', () => { vi.mocked(usePushNotifications).mockReturnValue({ isSupported: false, + unsupportedReason: null, permission: 'default', isSubscribed: false, isLoading: false, @@ -43,9 +45,27 @@ describe('NotificationPreferences', () => { expect(screen.getByText(/not supported/i)).toBeInTheDocument(); }); + it('shows the specific reason push is unavailable', () => { + vi.mocked(usePushNotifications).mockReturnValue({ + isSupported: false, + unsupportedReason: 'On iPhone and iPad, add this site to your Home Screen.', + permission: 'default', + isSubscribed: false, + isLoading: false, + subscribe: mockSubscribe, + unsubscribe: mockUnsubscribe, + }); + + render(); + + expect(screen.getByText(/add this site to your Home Screen/i)).toBeInTheDocument(); + expect(screen.queryByText(/not supported/i)).not.toBeInTheDocument(); + }); + it('should show enable button when not subscribed', () => { vi.mocked(usePushNotifications).mockReturnValue({ isSupported: true, + unsupportedReason: null, permission: 'default', isSubscribed: false, isLoading: false, @@ -62,6 +82,7 @@ describe('NotificationPreferences', () => { it('should show disable button when subscribed', () => { vi.mocked(usePushNotifications).mockReturnValue({ isSupported: true, + unsupportedReason: null, permission: 'granted', isSubscribed: true, isLoading: false, @@ -78,6 +99,7 @@ describe('NotificationPreferences', () => { it('should call subscribe when enable button is clicked', async () => { vi.mocked(usePushNotifications).mockReturnValue({ isSupported: true, + unsupportedReason: null, permission: 'default', isSubscribed: false, isLoading: false, @@ -97,6 +119,7 @@ describe('NotificationPreferences', () => { it('should call unsubscribe when disable button is clicked', async () => { vi.mocked(usePushNotifications).mockReturnValue({ isSupported: true, + unsupportedReason: null, permission: 'granted', isSubscribed: true, isLoading: false, @@ -116,6 +139,7 @@ describe('NotificationPreferences', () => { it('should show notification blocked message when permission is denied', () => { vi.mocked(usePushNotifications).mockReturnValue({ isSupported: true, + unsupportedReason: null, permission: 'denied', isSubscribed: false, isLoading: false, @@ -132,6 +156,7 @@ describe('NotificationPreferences', () => { it('should disable button while loading', () => { vi.mocked(usePushNotifications).mockReturnValue({ isSupported: true, + unsupportedReason: null, permission: 'default', isSubscribed: false, isLoading: true, @@ -151,6 +176,7 @@ describe('NotificationPreferences', () => { it('should show event toggles when subscribed', () => { vi.mocked(usePushNotifications).mockReturnValue({ isSupported: true, + unsupportedReason: null, permission: 'granted', isSubscribed: true, isLoading: false, @@ -170,6 +196,7 @@ describe('NotificationPreferences', () => { it('should not show event toggles when not subscribed', () => { vi.mocked(usePushNotifications).mockReturnValue({ isSupported: true, + unsupportedReason: null, permission: 'default', isSubscribed: false, isLoading: false, @@ -186,6 +213,7 @@ describe('NotificationPreferences', () => { it('should load preferences from server on mount', async () => { vi.mocked(usePushNotifications).mockReturnValue({ isSupported: true, + unsupportedReason: null, permission: 'granted', isSubscribed: true, isLoading: false, @@ -219,6 +247,7 @@ describe('NotificationPreferences', () => { it('should save preference when toggle is clicked', async () => { vi.mocked(usePushNotifications).mockReturnValue({ isSupported: true, + unsupportedReason: null, permission: 'granted', isSubscribed: true, isLoading: false, diff --git a/apps/web/src/components/notifications/NotificationPreferences.tsx b/apps/web/src/components/notifications/NotificationPreferences.tsx index f4050655..72f16b53 100644 --- a/apps/web/src/components/notifications/NotificationPreferences.tsx +++ b/apps/web/src/components/notifications/NotificationPreferences.tsx @@ -95,8 +95,15 @@ function Toggle({ } export function NotificationPreferences() { - const { isSupported, permission, isSubscribed, isLoading, subscribe, unsubscribe } = - usePushNotifications(); + const { + isSupported, + unsupportedReason, + permission, + isSubscribed, + isLoading, + subscribe, + unsubscribe, + } = usePushNotifications(); const [preferences, setPreferences] = useState(DEFAULT_PREFS); const [saving, setSaving] = useState(false); @@ -157,7 +164,7 @@ export function NotificationPreferences() { return (

- Push notifications are not supported in this browser. + {unsupportedReason ?? 'Push notifications are not supported in this browser.'}

); diff --git a/apps/web/src/hooks/usePushNotifications.test.ts b/apps/web/src/hooks/usePushNotifications.test.ts index e966f650..4ef8fadd 100644 --- a/apps/web/src/hooks/usePushNotifications.test.ts +++ b/apps/web/src/hooks/usePushNotifications.test.ts @@ -1,63 +1,49 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook, act } from '@testing-library/react'; -// Set VAPID key before module load (vi.hoisted runs before imports) -vi.hoisted(() => { - process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY = - 'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkOs-qy7505aFNGpOTN_4Bz8T5RA8ZjqO1QjAPGeZs'; +const mocks = vi.hoisted(() => { + class PushError extends Error { + reason: string; + constructor(reason: string, message = reason) { + super(message); + this.reason = reason; + } + } + return { + PushError, + pushSupport: vi.fn(), + getSubscription: vi.fn(), + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }; }); +vi.mock('@profullstack/notifications/client', () => mocks); + import { usePushNotifications } from './usePushNotifications'; -const mockSubscription = { +const subscriptionJson = { endpoint: 'https://fcm.googleapis.com/fcm/send/test-endpoint', - toJSON: () => ({ - endpoint: 'https://fcm.googleapis.com/fcm/send/test-endpoint', - keys: { p256dh: 'test-p256dh', auth: 'test-auth' }, - }), - unsubscribe: vi.fn().mockResolvedValue(true), + keys: { p256dh: 'test-p256dh', auth: 'test-auth' }, }; -const mockPushManager = { - getSubscription: vi.fn().mockResolvedValue(null), - subscribe: vi.fn().mockResolvedValue(mockSubscription), -}; +const mockSubscription = { endpoint: subscriptionJson.endpoint }; -const mockRegistration = { - pushManager: mockPushManager, -}; +const SUPPORTED = { supported: true, reason: null, message: null, permission: 'default' }; describe('usePushNotifications', () => { beforeEach(() => { vi.clearAllMocks(); - mockPushManager.getSubscription.mockResolvedValue(null); - mockPushManager.subscribe.mockResolvedValue(mockSubscription); - mockSubscription.unsubscribe.mockResolvedValue(true); - - // Mock browser APIs - Object.defineProperty(window, 'Notification', { - value: { - permission: 'default' as NotificationPermission, - requestPermission: vi.fn().mockResolvedValue('granted'), - }, - writable: true, - configurable: true, - }); - - Object.defineProperty(navigator, 'serviceWorker', { - value: { - ready: Promise.resolve(mockRegistration), - }, - writable: true, - configurable: true, - }); - - // Ensure PushManager exists - Object.defineProperty(window, 'PushManager', { - value: vi.fn(), - writable: true, - configurable: true, - }); + mocks.pushSupport.mockReturnValue(SUPPORTED); + mocks.getSubscription.mockResolvedValue(null); + mocks.unsubscribe.mockResolvedValue(true); + // The package hands the new subscription to `save`; PairUX's save POSTs it. + mocks.subscribe.mockImplementation( + async (opts: { save: (json: typeof subscriptionJson) => Promise }) => { + await opts.save(subscriptionJson); + return mockSubscription; + } + ); global.fetch = vi.fn().mockResolvedValue({ ok: true, @@ -69,17 +55,22 @@ describe('usePushNotifications', () => { const { result } = renderHook(() => usePushNotifications()); expect(result.current.isSupported).toBe(true); + expect(result.current.unsupportedReason).toBeNull(); expect(result.current.isLoading).toBe(false); }); - it('should detect unsupported browser', () => { - // Must delete the property so 'PushManager' in window returns false - // eslint-disable-next-line @typescript-eslint/no-explicit-any - delete (window as any).PushManager; + it('should detect unsupported browser and say why', () => { + mocks.pushSupport.mockReturnValue({ + supported: false, + reason: 'ios-needs-install', + message: 'On iPhone and iPad, add this site to your Home Screen.', + permission: 'unsupported', + }); const { result } = renderHook(() => usePushNotifications()); expect(result.current.isSupported).toBe(false); + expect(result.current.unsupportedReason).toMatch(/Home Screen/); }); it('should start as not subscribed', () => { @@ -89,7 +80,7 @@ describe('usePushNotifications', () => { }); it('should detect existing subscription on mount', async () => { - mockPushManager.getSubscription.mockResolvedValueOnce(mockSubscription); + mocks.getSubscription.mockResolvedValueOnce(mockSubscription); const { result } = renderHook(() => usePushNotifications()); @@ -100,7 +91,7 @@ describe('usePushNotifications', () => { expect(result.current.isSubscribed).toBe(true); }); - it('should subscribe successfully', async () => { + it('should subscribe with the runtime VAPID key endpoint', async () => { const { result } = renderHook(() => usePushNotifications()); let success = false; @@ -110,6 +101,12 @@ describe('usePushNotifications', () => { expect(success).toBe(true); expect(result.current.isSubscribed).toBe(true); + expect(mocks.subscribe).toHaveBeenCalledWith( + expect.objectContaining({ + vapidKeyUrl: '/api/push/vapid-public-key', + serviceWorkerUrl: '/sw.js', + }) + ); expect(global.fetch).toHaveBeenCalledWith( '/api/push/subscribe', expect.objectContaining({ method: 'POST' }) @@ -117,8 +114,8 @@ describe('usePushNotifications', () => { }); it('should return false when permission is denied', async () => { - (window.Notification.requestPermission as ReturnType).mockResolvedValueOnce( - 'denied' + mocks.subscribe.mockRejectedValueOnce( + new mocks.PushError('denied', 'Notifications are blocked for this site.') ); const { result } = renderHook(() => usePushNotifications()); @@ -130,11 +127,16 @@ describe('usePushNotifications', () => { expect(success).toBe(false); expect(result.current.isSubscribed).toBe(false); + expect(result.current.permission).toBe('denied'); }); it('should return false when not supported', async () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - delete (window as any).PushManager; + mocks.pushSupport.mockReturnValue({ + supported: false, + reason: 'no-push-manager', + message: 'This browser does not support push notifications.', + permission: 'unsupported', + }); const { result } = renderHook(() => usePushNotifications()); @@ -144,10 +146,11 @@ describe('usePushNotifications', () => { }); expect(success).toBe(false); + expect(mocks.subscribe).not.toHaveBeenCalled(); }); it('should unsubscribe successfully', async () => { - mockPushManager.getSubscription.mockResolvedValueOnce(mockSubscription); + mocks.getSubscription.mockResolvedValue(mockSubscription); const { result } = renderHook(() => usePushNotifications()); @@ -164,7 +167,7 @@ describe('usePushNotifications', () => { expect(success).toBe(true); expect(result.current.isSubscribed).toBe(false); - expect(mockSubscription.unsubscribe).toHaveBeenCalled(); + expect(mocks.unsubscribe).toHaveBeenCalled(); expect(global.fetch).toHaveBeenCalledWith( '/api/push/unsubscribe', expect.objectContaining({ method: 'POST' }) @@ -180,6 +183,7 @@ describe('usePushNotifications', () => { }); expect(success).toBe(true); + expect(global.fetch).not.toHaveBeenCalledWith('/api/push/unsubscribe', expect.anything()); }); it('should include participantId when provided', async () => { diff --git a/apps/web/src/hooks/usePushNotifications.ts b/apps/web/src/hooks/usePushNotifications.ts index ec65f7bd..1b9e038b 100644 --- a/apps/web/src/hooks/usePushNotifications.ts +++ b/apps/web/src/hooks/usePushNotifications.ts @@ -1,14 +1,23 @@ 'use client'; -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback } from 'react'; +import { + PushError, + getSubscription, + pushSupport, + subscribe as subscribePush, + unsubscribe as unsubscribePush, +} from '@profullstack/notifications/client'; interface UsePushNotificationsOptions { participantId?: string; } interface UsePushNotificationsReturn { - /** Whether push notifications are supported in this browser */ + /** Whether push notifications can work in this browser right now */ isSupported: boolean; + /** Why not, in a sentence to show the user (null when supported) */ + unsupportedReason: string | null; /** Current notification permission state */ permission: NotificationPermission; /** Whether the user is currently subscribed */ @@ -21,51 +30,29 @@ interface UsePushNotificationsReturn { unsubscribe: () => Promise; } -const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY ?? ''; - -function urlBase64ToUint8Array(base64String: string): BufferSource { - 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.buffer; -} - export function usePushNotifications( options: UsePushNotificationsOptions = {} ): UsePushNotificationsReturn { const { participantId } = options; const [isSupported, setIsSupported] = useState(false); + const [unsupportedReason, setUnsupportedReason] = useState(null); const [permission, setPermission] = useState('default'); const [isSubscribed, setIsSubscribed] = useState(false); const [isLoading, setIsLoading] = useState(false); - const subscriptionRef = useRef(null); - // Check support and current subscription state on mount + // Check support and current subscription state on mount. The VAPID key is + // fetched from the server when subscribing, so a build without it no longer + // makes every browser look unsupported. useEffect(() => { - const supported = - typeof window !== 'undefined' && - 'serviceWorker' in navigator && - 'PushManager' in window && - 'Notification' in window && - !!VAPID_PUBLIC_KEY; - - setIsSupported(supported); - - if (!supported) return; - - setPermission(Notification.permission); - - void navigator.serviceWorker.ready.then(async (registration) => { - const existing = await registration.pushManager.getSubscription(); - if (existing) { - subscriptionRef.current = existing; - setIsSubscribed(true); - } + const support = pushSupport(); + setIsSupported(support.supported); + setUnsupportedReason(support.message); + if (support.permission !== 'unsupported') setPermission(support.permission); + if (!support.supported) return; + + void getSubscription().then((existing) => { + setIsSubscribed(existing !== null); }); }, []); @@ -74,46 +61,30 @@ export function usePushNotifications( setIsLoading(true); try { - const perm = await Notification.requestPermission(); - setPermission(perm); - - if (perm !== 'granted') { - return false; - } - - const registration = await navigator.serviceWorker.ready; - - const subscription = await registration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY), + await subscribePush({ + vapidKeyUrl: '/api/push/vapid-public-key', + serviceWorkerUrl: '/sw.js', + save: async (json) => { + const response = await fetch('/api/push/subscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + endpoint: json.endpoint, + keys: json.keys, + ...(participantId ? { participantId } : {}), + }), + }); + if (!response.ok) throw new Error('Failed to save subscription on server'); + }, }); - - subscriptionRef.current = subscription; - - const json = subscription.toJSON(); - const keys = json.keys as { p256dh: string; auth: string } | undefined; - - if (!keys?.p256dh || !keys.auth) { - throw new Error('Missing subscription keys'); - } - - const response = await fetch('/api/push/subscribe', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - endpoint: subscription.endpoint, - keys: { p256dh: keys.p256dh, auth: keys.auth }, - ...(participantId ? { participantId } : {}), - }), - }); - - if (!response.ok) { - throw new Error('Failed to save subscription on server'); - } - + setPermission('granted'); setIsSubscribed(true); return true; } catch (err) { + if (err instanceof PushError) { + if (err.reason === 'denied') setPermission('denied'); + setUnsupportedReason(err.message); + } console.error('[Push] Subscribe error:', err); return false; } finally { @@ -124,18 +95,15 @@ export function usePushNotifications( const unsubscribe = useCallback(async (): Promise => { setIsLoading(true); try { - const subscription = subscriptionRef.current; - if (!subscription) return true; - - await subscription.unsubscribe(); - - await fetch('/api/push/unsubscribe', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ endpoint: subscription.endpoint }), - }); - - subscriptionRef.current = null; + const existing = await getSubscription(); + await unsubscribePush(); + if (existing) { + await fetch('/api/push/unsubscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ endpoint: existing.endpoint }), + }); + } setIsSubscribed(false); return true; } catch (err) { @@ -148,6 +116,7 @@ export function usePushNotifications( return { isSupported, + unsupportedReason, permission, isSubscribed, isLoading, diff --git a/apps/web/src/lib/push.test.ts b/apps/web/src/lib/push.test.ts index 1ad15f91..7f11e397 100644 --- a/apps/web/src/lib/push.test.ts +++ b/apps/web/src/lib/push.test.ts @@ -1,16 +1,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; // Use vi.hoisted so the mock fn is available in the vi.mock factory -const { mockSendNotification, mockFrom } = vi.hoisted(() => ({ - mockSendNotification: vi.fn(), +const { mockSendPush, mockFrom } = vi.hoisted(() => ({ + mockSendPush: vi.fn(), mockFrom: vi.fn(), })); -vi.mock('web-push', () => ({ - default: { - setVapidDetails: vi.fn(), - sendNotification: mockSendNotification, - }, +vi.mock('@profullstack/notifications/server', () => ({ + vapidKeysFromEnv: () => ({ publicKey: 'test-public-key', privateKey: 'test-private-key' }), + sendPush: mockSendPush, })); vi.mock('@supabase/supabase-js', () => ({ @@ -46,7 +44,7 @@ function mockChain(resolvedValue: unknown) { describe('push utility', () => { beforeEach(() => { vi.clearAllMocks(); - mockSendNotification.mockResolvedValue({}); + mockSendPush.mockResolvedValue({ sent: true, gone: false, status: 201 }); }); describe('sendPushToUser', () => { @@ -63,7 +61,7 @@ describe('push utility', () => { }); expect(result).toEqual({ sent: 0, failed: 0 }); - expect(mockSendNotification).not.toHaveBeenCalled(); + expect(mockSendPush).not.toHaveBeenCalled(); }); it('should skip if the specific event type is disabled', async () => { @@ -79,7 +77,7 @@ describe('push utility', () => { }); expect(result).toEqual({ sent: 0, failed: 0 }); - expect(mockSendNotification).not.toHaveBeenCalled(); + expect(mockSendPush).not.toHaveBeenCalled(); }); it('should send notification to all user subscriptions', async () => { @@ -106,7 +104,7 @@ describe('push utility', () => { }); expect(result).toEqual({ sent: 2, failed: 0 }); - expect(mockSendNotification).toHaveBeenCalledTimes(2); + expect(mockSendPush).toHaveBeenCalledTimes(2); }); it('should return 0/0 when user has no subscriptions', async () => { @@ -150,7 +148,7 @@ describe('push utility', () => { return mockChain({ data: null, error: null }); }); - mockSendNotification.mockRejectedValue({ statusCode: 410 }); + mockSendPush.mockResolvedValue({ sent: false, gone: true, status: 410 }); const result = await sendPushToUser('user-1', 'chatMessage', { title: 'Test', @@ -178,7 +176,7 @@ describe('push utility', () => { return mockChain({ data: subscriptions, error: null }); }); - mockSendNotification.mockRejectedValue({ statusCode: 500 }); + mockSendPush.mockResolvedValue({ sent: false, gone: false, status: 500 }); const result = await sendPushToUser('user-1', 'chatMessage', { title: 'Test', @@ -203,7 +201,7 @@ describe('push utility', () => { }); expect(result).toEqual({ sent: 1, failed: 0 }); - expect(mockSendNotification).toHaveBeenCalledTimes(1); + expect(mockSendPush).toHaveBeenCalledTimes(1); }); it('should return 0/0 when participant has no subscriptions', async () => { diff --git a/apps/web/src/lib/push.ts b/apps/web/src/lib/push.ts index bcb062b2..eb93b03c 100644 --- a/apps/web/src/lib/push.ts +++ b/apps/web/src/lib/push.ts @@ -1,4 +1,4 @@ -import webpush from 'web-push'; +import { sendPush, vapidKeysFromEnv, type VapidKeys } from '@profullstack/notifications/server'; import { createClient } from '@supabase/supabase-js'; const DEFAULT_PREFERENCES = { @@ -31,22 +31,21 @@ interface PushPayload { tag?: string; } -// Lazy-initialize web-push with VAPID details -let initialized = false; +const PUSH_SUBJECT = 'mailto:support@pairux.com'; -function initWebPush(): boolean { - if (initialized) return true; - - const publicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY; - const privateKey = process.env.VAPID_PRIVATE_KEY; +// VAPID keys, read at RUN time. `process.env.NEXT_PUBLIC_…` written out in +// code is replaced by Next at build time (even on the server), so a key that +// was missing from the build compiled to undefined and silently disabled every +// push; vapidKeysFromEnv looks the names up dynamically instead. +let vapidKeys: VapidKeys | null = null; - if (!publicKey || !privateKey) { - console.warn('[Push] Missing VAPID environment variables, push notifications disabled'); +function initWebPush(): boolean { + if (vapidKeys) return true; + vapidKeys = vapidKeysFromEnv(process.env); + if (!vapidKeys) { + console.warn('[Push] Missing VAPID keys (VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY); push disabled'); return false; } - - webpush.setVapidDetails('mailto:support@pairux.com', publicKey, privateKey); - initialized = true; return true; } @@ -188,23 +187,19 @@ async function sendToSubscriptions( let failed = 0; const staleIds: string[] = []; - const jsonPayload = JSON.stringify(payload); + const keys = vapidKeys; + if (!keys) return { sent: 0, failed: subscriptions.length }; - await Promise.allSettled( + await Promise.all( subscriptions.map(async (sub) => { - try { - await webpush.sendNotification( - { endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } }, - jsonPayload - ); - sent++; - } catch (err: unknown) { - const error = err as { statusCode?: number }; - if (error.statusCode === 404 || error.statusCode === 410) { - staleIds.push(sub.id); - } - failed++; - } + const result = await sendPush( + { endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } }, + payload, + { keys, subject: PUSH_SUBJECT } + ); + if (result.sent) sent++; + else failed++; + if (result.gone) staleIds.push(sub.id); }) ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39827ddb..9368944e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -362,6 +362,9 @@ importers: '@profullstack/emailer': specifier: ^1.0.3 version: 1.0.3 + '@profullstack/notifications': + specifier: 0.1.0 + version: 0.1.0 '@profullstack/stack': specifier: ^0.1.3 version: 0.1.3(@supabase/ssr@0.5.2(@supabase/supabase-js@2.91.0))(next@16.3.3(@types/node@22.19.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) @@ -419,9 +422,6 @@ importers: tailwind-merge: specifier: ^2.6.0 version: 2.6.0 - web-push: - specifier: ^3.6.7 - version: 3.6.7 zod: specifier: ^3.24.0 version: 3.25.76 @@ -453,9 +453,6 @@ importers: '@types/react-dom': specifier: ^19.0.0 version: 19.2.3(@types/react@19.2.9) - '@types/web-push': - specifier: ^3.6.4 - version: 3.6.4 autoprefixer: specifier: ^10.4.20 version: 10.4.23(postcss@8.5.6) @@ -2797,6 +2794,10 @@ packages: resolution: {integrity: sha512-Vys47LvS5ivhdZu4SYOxh9Le3DEmWhjP0+CtrU45RhXAbY0ZFG/2Dk9V1bIIUGDp8UWMJqctvUD21JrNjfh04w==} engines: {node: '>=20.11'} + '@profullstack/notifications@0.1.0': + resolution: {integrity: sha512-17jiBCRiZVDcIyWnWKYr5E2eVog/1iHgXaruGbmlw7j7Ob4zd6oOqGjh8BP8wm13iFwikczYObQwSrGadBdnGw==} + engines: {node: '>=20.11'} + '@profullstack/referrals@0.1.0': resolution: {integrity: sha512-u66SdBVpsv3kc0N+NWISPoYD5vjCERyv5wfD07iSkZwQeC2IA+ihX5jNA4e7Xr+Y4AUvxLycG+3b4VaROqzgRg==} engines: {node: '>=18'} @@ -3475,9 +3476,6 @@ packages: '@types/verror@1.10.11': resolution: {integrity: sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==} - '@types/web-push@3.6.4': - resolution: {integrity: sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==} - '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -3867,9 +3865,6 @@ packages: asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} - asn1.js@5.4.1: - resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} - asn1@0.2.6: resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} @@ -4051,9 +4046,6 @@ packages: bmp-js@0.1.0: resolution: {integrity: sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==} - bn.js@4.12.2: - resolution: {integrity: sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==} - boolean@3.2.0: resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. @@ -4103,9 +4095,6 @@ packages: buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} - buffer-equal-constant-time@1.0.1: - resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} - buffer-equal@0.0.1: resolution: {integrity: sha512-RgSV6InVQ9ODPdLWJ5UAqBqJBOg370Nz6ZQtRzpt6nUjc8v0St97uJ4PYC6NztqIScrAXafKM3mZPMygSe1ggA==} engines: {node: '>=0.4.0'} @@ -4707,9 +4696,6 @@ packages: ecc-jsbn@0.1.2: resolution: {integrity: sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==} - ecdsa-sig-formatter@1.0.11: - resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} - ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -5596,10 +5582,6 @@ packages: resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==} engines: {node: '>=10.19.0'} - http_ece@1.2.0: - resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==} - engines: {node: '>=16'} - https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} @@ -6129,12 +6111,6 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} - jwa@2.0.1: - resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} - - jws@4.0.1: - resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} - keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -6592,9 +6568,6 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} - minimalistic-assert@1.0.1: - resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} - minimatch@10.1.1: resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} engines: {node: 20 || >=22} @@ -8789,11 +8762,6 @@ packages: wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} - web-push@3.6.7: - resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==} - engines: {node: '>= 16'} - hasBin: true - web-streams-polyfill@3.3.3: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} @@ -11766,6 +11734,8 @@ snapshots: '@profullstack/keys@0.1.0': {} + '@profullstack/notifications@0.1.0': {} + '@profullstack/referrals@0.1.0(react@19.2.3)': optionalDependencies: react: 19.2.3 @@ -12489,10 +12459,6 @@ snapshots: '@types/verror@1.10.11': optional: true - '@types/web-push@3.6.4': - dependencies: - '@types/node': 22.19.7 - '@types/ws@8.18.1': dependencies: '@types/node': 22.19.7 @@ -12665,6 +12631,14 @@ snapshots: optionalDependencies: vite: 5.4.21(@types/node@22.19.7)(lightningcss@1.30.2)(terser@5.46.0) + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@24.10.9)(lightningcss@1.30.2)(terser@5.46.0))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@24.10.9)(lightningcss@1.30.2)(terser@5.46.0) + '@vitest/mocker@3.2.6(vite@5.4.21(@types/node@22.19.7)(lightningcss@1.30.2)(terser@5.46.0))': dependencies: '@vitest/spy': 3.2.6 @@ -12673,6 +12647,14 @@ snapshots: optionalDependencies: vite: 5.4.21(@types/node@22.19.7)(lightningcss@1.30.2)(terser@5.46.0) + '@vitest/mocker@3.2.6(vite@5.4.21(@types/node@24.10.9)(lightningcss@1.30.2)(terser@5.46.0))': + dependencies: + '@vitest/spy': 3.2.6 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@24.10.9)(lightningcss@1.30.2)(terser@5.46.0) + '@vitest/pretty-format@2.1.9': dependencies: tinyrainbow: 1.2.0 @@ -13052,13 +13034,6 @@ snapshots: asap@2.0.6: {} - asn1.js@5.4.1: - dependencies: - bn.js: 4.12.2 - inherits: 2.0.4 - minimalistic-assert: 1.0.1 - safer-buffer: 2.1.2 - asn1@0.2.6: dependencies: safer-buffer: 2.1.2 @@ -13273,8 +13248,6 @@ snapshots: bmp-js@0.1.0: {} - bn.js@4.12.2: {} - boolean@3.2.0: optional: true @@ -13332,8 +13305,6 @@ snapshots: buffer-crc32@0.2.13: {} - buffer-equal-constant-time@1.0.1: {} - buffer-equal@0.0.1: {} buffer-fill@1.0.0: {} @@ -14023,10 +13994,6 @@ snapshots: safer-buffer: 2.1.2 optional: true - ecdsa-sig-formatter@1.0.11: - dependencies: - safe-buffer: 5.2.1 - ee-first@1.1.1: {} ejs@3.1.10: @@ -15233,8 +15200,6 @@ snapshots: quick-lru: 5.1.1 resolve-alpn: 1.2.1 - http_ece@1.2.0: {} - https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 @@ -15818,17 +15783,6 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 - jwa@2.0.1: - dependencies: - buffer-equal-constant-time: 1.0.1 - ecdsa-sig-formatter: 1.0.11 - safe-buffer: 5.2.1 - - jws@4.0.1: - dependencies: - jwa: 2.0.1 - safe-buffer: 5.2.1 - keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -16365,8 +16319,6 @@ snapshots: min-indent@1.0.1: {} - minimalistic-assert@1.0.1: {} - minimatch@10.1.1: dependencies: '@isaacs/brace-expansion': 5.0.0 @@ -18751,7 +18703,7 @@ snapshots: vitest@2.1.9(@types/node@24.10.9)(jsdom@25.0.1)(lightningcss@1.30.2)(terser@5.46.0): dependencies: '@vitest/expect': 2.1.9 - '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.7)(lightningcss@1.30.2)(terser@5.46.0)) + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@24.10.9)(lightningcss@1.30.2)(terser@5.46.0)) '@vitest/pretty-format': 2.1.9 '@vitest/runner': 2.1.9 '@vitest/snapshot': 2.1.9 @@ -18828,7 +18780,7 @@ snapshots: dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.6 - '@vitest/mocker': 3.2.6(vite@5.4.21(@types/node@22.19.7)(lightningcss@1.30.2)(terser@5.46.0)) + '@vitest/mocker': 3.2.6(vite@5.4.21(@types/node@24.10.9)(lightningcss@1.30.2)(terser@5.46.0)) '@vitest/pretty-format': 3.2.6 '@vitest/runner': 3.2.6 '@vitest/snapshot': 3.2.6 @@ -18880,16 +18832,6 @@ snapshots: dependencies: defaults: 1.0.4 - web-push@3.6.7: - dependencies: - asn1.js: 5.4.1 - http_ece: 1.2.0 - https-proxy-agent: 7.0.6 - jws: 4.0.1 - minimist: 1.2.8 - transitivePeerDependencies: - - supports-color - web-streams-polyfill@3.3.3: {} web-vitals@5.3.0: {}