feat: add Web Push API support and PWA integration (#189)

* feat: add Web Push API support and PWA integration

Add web Push API with subscribe/unsubscribe and visibility endpoints
Introduce usePushVisibilityBeacon and useSessionDeepLink hooks
Integrate PWA with service worker, registerSW, and VAPID key persistence

* feat: add heartbeat visibility beacon for web runtime

Add a 10s heartbeat to ping visibility while visible
Subscribe to visibilitychange, focus, blur, pageshow, and pagehide events to report state
Clear heartbeat interval on unmount to avoid leaks
This commit is contained in:
Bohdan Triapitsyn
2026-01-22 01:19:24 +02:00
committed by GitHub
parent 06c5e821a4
commit 1f23b63c0b
19 changed files with 3527 additions and 42 deletions
+2
View File
@@ -6,6 +6,7 @@ import { createWebSettingsAPI } from './settings';
import { createWebPermissionsAPI } from './permissions';
import { createWebNotificationsAPI } from './notifications';
import { createWebToolsAPI } from './tools';
import { createWebPushAPI } from './push';
export const createWebAPIs = (): RuntimeAPIs => ({
runtime: { platform: 'web', isDesktop: false, isVSCode: false, label: 'web' },
@@ -15,5 +16,6 @@ export const createWebAPIs = (): RuntimeAPIs => ({
settings: createWebSettingsAPI(),
permissions: createWebPermissionsAPI(),
notifications: createWebNotificationsAPI(),
push: createWebPushAPI(),
tools: createWebToolsAPI(),
});
+9 -2
View File
@@ -6,8 +6,15 @@ const notifyWithWebAPI = async (payload?: NotificationPayload): Promise<boolean>
return false;
}
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
if (Notification.permission === 'default') {
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
console.warn('Notification permission not granted');
return false;
}
}
if (Notification.permission !== 'granted') {
console.warn('Notification permission not granted');
return false;
}
+59
View File
@@ -0,0 +1,59 @@
import type { PushAPI, PushSubscribePayload, PushUnsubscribePayload } from '@openchamber/ui/lib/api/types';
const fetchJson = async <T>(input: RequestInfo | URL, init?: RequestInit): Promise<T | null> => {
try {
const res = await fetch(input, {
...init,
credentials: 'include',
headers: {
Accept: 'application/json',
...(init?.headers ?? {}),
},
});
if (!res.ok) {
return null;
}
return (await res.json()) as T;
} catch {
return null;
}
};
export const createWebPushAPI = (): PushAPI => ({
async getVapidPublicKey() {
return fetchJson<{ publicKey: string }>('/api/push/vapid-public-key');
},
async subscribe(payload: PushSubscribePayload) {
return fetchJson<{ ok: true }>('/api/push/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
},
async unsubscribe(payload: PushUnsubscribePayload) {
return fetchJson<{ ok: true }>('/api/push/subscribe', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
},
async setVisibility(payload: { visible: boolean }) {
return fetchJson<{ ok: true }>('/api/push/visibility', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
keepalive: true,
});
},
});