@swimmesberger/elarion-webpush 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -0
- package/dist/api.d.ts +42 -0
- package/dist/api.js +40 -0
- package/dist/base64url.d.ts +7 -0
- package/dist/base64url.js +22 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.js +110 -0
- package/dist/sw.d.ts +50 -0
- package/dist/sw.js +92 -0
- package/package.json +47 -0
package/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# @swimmesberger/elarion-webpush
|
|
2
|
+
|
|
3
|
+
The browser half of [Elarion Web Push](https://elarion.wimmesberger.dev/docs/capabilities/web-push): reach a
|
|
4
|
+
user's browser or installed PWA while the app is closed. The server half is the `Elarion.WebPush` NuGet
|
|
5
|
+
package (`IWebPushSender`, VAPID keys, the subscription store).
|
|
6
|
+
|
|
7
|
+
- `pushAvailability()` → `'available' | 'install-first' | 'unsupported'`. `install-first` is iOS/iPadOS Safari
|
|
8
|
+
outside a Home Screen app, where `PushManager` does not exist yet: show "Add to Home Screen first" instead of
|
|
9
|
+
a switch that can never turn on.
|
|
10
|
+
- `enablePush(api)` — asks for permission **inside the click handler** (before any `await`, which Safari
|
|
11
|
+
requires) and subscribes.
|
|
12
|
+
- `subscribe`, `unsubscribe`, `isSubscribed`, and `refreshOnStart(api)` — call the last one on every app start
|
|
13
|
+
to heal subscriptions the push service rotated or the server cleaned up.
|
|
14
|
+
- `@swimmesberger/elarion-webpush/sw` — `registerWebPushHandlers(self)` for the service worker: shows the
|
|
15
|
+
notification, focuses or opens the app at its URL on click, and re-subscribes on `pushsubscriptionchange`.
|
|
16
|
+
|
|
17
|
+
## Page
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { enablePush, fetchWebPushApi, pushAvailability, refreshOnStart } from '@swimmesberger/elarion-webpush'
|
|
21
|
+
|
|
22
|
+
const api = fetchWebPushApi() // the endpoints app.MapElarionWebPush() maps under /webpush
|
|
23
|
+
|
|
24
|
+
await navigator.serviceWorker.register('/sw.js', { type: 'module' })
|
|
25
|
+
void refreshOnStart(api)
|
|
26
|
+
|
|
27
|
+
switch (pushAvailability()) {
|
|
28
|
+
case 'available':
|
|
29
|
+
button.onclick = async () => console.log(await enablePush(api)) // 'subscribed' | 'denied' | 'dismissed'
|
|
30
|
+
break
|
|
31
|
+
case 'install-first':
|
|
32
|
+
hint.textContent = 'Add this app to your Home Screen to turn on notifications.'
|
|
33
|
+
break
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
When the application exposes its own `webPush.*` handlers instead of `MapElarionWebPush`, adapt the generated
|
|
38
|
+
JSON-RPC client:
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
const api: WebPushServerApi = {
|
|
42
|
+
getPublicKey: async () => (await rpc.webPush.publicKey({})).publicKey,
|
|
43
|
+
subscribe: async (subscription) => { await rpc.webPush.subscribe(subscription) },
|
|
44
|
+
unsubscribe: async (endpoint) => { await rpc.webPush.unsubscribe({ endpoint }) },
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Service worker
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { registerWebPushHandlers } from '@swimmesberger/elarion-webpush/sw'
|
|
52
|
+
|
|
53
|
+
registerWebPushHandlers(self, { icon: '/icons/192.png', badge: '/icons/badge.png' })
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The module is ESM: bundle the worker (e.g. vite-plugin-pwa `injectManifest`) or register it with
|
|
57
|
+
`{ type: 'module' }`.
|
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** A browser subscription as `PushSubscription.toJSON()` serializes it — the body the server stores. */
|
|
2
|
+
export interface WebPushSubscriptionJson {
|
|
3
|
+
endpoint: string;
|
|
4
|
+
expirationTime?: number | null;
|
|
5
|
+
keys: {
|
|
6
|
+
p256dh: string;
|
|
7
|
+
auth: string;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The three server calls the helpers need. Use {@link fetchWebPushApi} with `MapElarionWebPush()`, or adapt the
|
|
12
|
+
* generated JSON-RPC client when the application exposes its own `webPush.*` handlers:
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* const api: WebPushServerApi = {
|
|
16
|
+
* getPublicKey: async () => (await rpc.webPush.publicKey({})).publicKey,
|
|
17
|
+
* subscribe: async (subscription) => { await rpc.webPush.subscribe(subscription) },
|
|
18
|
+
* unsubscribe: async (endpoint) => { await rpc.webPush.unsubscribe({ endpoint }) },
|
|
19
|
+
* }
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export interface WebPushServerApi {
|
|
23
|
+
/** The VAPID public key (base64url) to subscribe against. */
|
|
24
|
+
getPublicKey(): Promise<string>;
|
|
25
|
+
/** Stores the subscription for the signed-in user (idempotent). */
|
|
26
|
+
subscribe(subscription: WebPushSubscriptionJson): Promise<void>;
|
|
27
|
+
/** Deletes the signed-in user's subscription for the endpoint. */
|
|
28
|
+
unsubscribe(endpoint: string): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
/** Options for {@link fetchWebPushApi}. */
|
|
31
|
+
export interface FetchWebPushApiOptions {
|
|
32
|
+
/** The prefix `MapElarionWebPush` was mapped at. Defaults to `/webpush`. */
|
|
33
|
+
baseUrl?: string;
|
|
34
|
+
/** Extra headers per request, e.g. a bearer token. Cookies are sent same-origin by default. */
|
|
35
|
+
headers?: () => HeadersInit | Promise<HeadersInit>;
|
|
36
|
+
/** The `fetch` implementation. Defaults to the global one. */
|
|
37
|
+
fetch?: typeof fetch;
|
|
38
|
+
}
|
|
39
|
+
/** A {@link WebPushServerApi} over the endpoints `app.MapElarionWebPush()` maps. */
|
|
40
|
+
export declare function fetchWebPushApi(options?: FetchWebPushApiOptions): WebPushServerApi;
|
|
41
|
+
/** Serializes a live subscription, failing loudly if the browser omitted its keys. */
|
|
42
|
+
export declare function toSubscriptionJson(subscription: PushSubscription): WebPushSubscriptionJson;
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** A {@link WebPushServerApi} over the endpoints `app.MapElarionWebPush()` maps. */
|
|
2
|
+
export function fetchWebPushApi(options = {}) {
|
|
3
|
+
const baseUrl = (options.baseUrl ?? '/webpush').replace(/\/+$/, '');
|
|
4
|
+
async function call(path, body) {
|
|
5
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
6
|
+
const headers = new Headers(options.headers ? await options.headers() : undefined);
|
|
7
|
+
if (body !== undefined)
|
|
8
|
+
headers.set('Content-Type', 'application/json');
|
|
9
|
+
const response = await doFetch(`${baseUrl}${path}`, {
|
|
10
|
+
method: body === undefined ? 'GET' : 'POST',
|
|
11
|
+
headers,
|
|
12
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
13
|
+
credentials: 'same-origin',
|
|
14
|
+
});
|
|
15
|
+
if (!response.ok)
|
|
16
|
+
throw new Error(`Web Push ${path} failed with HTTP ${response.status}.`);
|
|
17
|
+
return response;
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
async getPublicKey() {
|
|
21
|
+
const { publicKey } = (await (await call('/public-key')).json());
|
|
22
|
+
return publicKey;
|
|
23
|
+
},
|
|
24
|
+
async subscribe(subscription) {
|
|
25
|
+
await call('/subscribe', subscription);
|
|
26
|
+
},
|
|
27
|
+
async unsubscribe(endpoint) {
|
|
28
|
+
await call('/unsubscribe', { endpoint });
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** Serializes a live subscription, failing loudly if the browser omitted its keys. */
|
|
33
|
+
export function toSubscriptionJson(subscription) {
|
|
34
|
+
const json = subscription.toJSON();
|
|
35
|
+
const p256dh = json.keys?.['p256dh'];
|
|
36
|
+
const auth = json.keys?.['auth'];
|
|
37
|
+
if (!json.endpoint || !p256dh || !auth)
|
|
38
|
+
throw new Error('The push subscription has no endpoint or keys.');
|
|
39
|
+
return { endpoint: json.endpoint, expirationTime: json.expirationTime ?? null, keys: { p256dh, auth } };
|
|
40
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decodes a base64url string (the VAPID public key as the server hands it out) into the bytes
|
|
3
|
+
* `pushManager.subscribe({ applicationServerKey })` expects.
|
|
4
|
+
*/
|
|
5
|
+
export declare function urlBase64ToUint8Array(value: string): Uint8Array<ArrayBuffer>;
|
|
6
|
+
/** Whether a subscription's `options.applicationServerKey` is the given key. */
|
|
7
|
+
export declare function isSameKey(current: ArrayBuffer | null | undefined, expected: Uint8Array): boolean;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decodes a base64url string (the VAPID public key as the server hands it out) into the bytes
|
|
3
|
+
* `pushManager.subscribe({ applicationServerKey })` expects.
|
|
4
|
+
*/
|
|
5
|
+
export function urlBase64ToUint8Array(value) {
|
|
6
|
+
const base64 = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (value.length % 4)) % 4);
|
|
7
|
+
const binary = atob(base64);
|
|
8
|
+
const bytes = new Uint8Array(new ArrayBuffer(binary.length));
|
|
9
|
+
for (let i = 0; i < binary.length; i++)
|
|
10
|
+
bytes[i] = binary.charCodeAt(i);
|
|
11
|
+
return bytes;
|
|
12
|
+
}
|
|
13
|
+
/** Whether a subscription's `options.applicationServerKey` is the given key. */
|
|
14
|
+
export function isSameKey(current, expected) {
|
|
15
|
+
if (!current || current.byteLength !== expected.byteLength)
|
|
16
|
+
return false;
|
|
17
|
+
const actual = new Uint8Array(current);
|
|
18
|
+
for (let i = 0; i < actual.length; i++)
|
|
19
|
+
if (actual[i] !== expected[i])
|
|
20
|
+
return false;
|
|
21
|
+
return true;
|
|
22
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type WebPushServerApi } from './api.js';
|
|
2
|
+
export { fetchWebPushApi, toSubscriptionJson, type FetchWebPushApiOptions, type WebPushServerApi, type WebPushSubscriptionJson, } from './api.js';
|
|
3
|
+
export { urlBase64ToUint8Array } from './base64url.js';
|
|
4
|
+
/**
|
|
5
|
+
* Whether this browser can receive pushes:
|
|
6
|
+
* - `available` — offer the switch.
|
|
7
|
+
* - `install-first` — iOS/iPadOS Safari outside a Home Screen app, where `PushManager` does not exist until
|
|
8
|
+
* the site is added to the Home Screen (iOS 16.4+). Say "add to Home Screen first" instead of offering a
|
|
9
|
+
* switch that can never turn on.
|
|
10
|
+
* - `unsupported` — no Web Push here (an old browser or iOS before 16.4, a non-secure context, …).
|
|
11
|
+
*/
|
|
12
|
+
export type PushAvailability = 'available' | 'install-first' | 'unsupported';
|
|
13
|
+
/** What {@link enablePush} achieved. `dismissed` means the prompt was closed without a choice — it may be offered again. */
|
|
14
|
+
export type EnablePushOutcome = 'subscribed' | 'denied' | 'dismissed' | 'install-first' | 'unsupported';
|
|
15
|
+
/** Options shared by the subscription helpers. */
|
|
16
|
+
export interface PushHelperOptions {
|
|
17
|
+
/**
|
|
18
|
+
* The service-worker registration to subscribe through. Defaults to `navigator.serviceWorker.ready` — which
|
|
19
|
+
* never settles when no service worker is registered, so register one before calling the helpers.
|
|
20
|
+
*/
|
|
21
|
+
registration?: ServiceWorkerRegistration;
|
|
22
|
+
}
|
|
23
|
+
/** Detects push support; see {@link PushAvailability}. Safe to call during rendering. */
|
|
24
|
+
export declare function pushAvailability(): PushAvailability;
|
|
25
|
+
/**
|
|
26
|
+
* Asks for notification permission and subscribes. Call it directly from a click handler: the permission
|
|
27
|
+
* request is issued before the first `await`, because Safari only honours one made synchronously inside the
|
|
28
|
+
* user gesture.
|
|
29
|
+
*/
|
|
30
|
+
export declare function enablePush(api: WebPushServerApi, options?: PushHelperOptions): Promise<EnablePushOutcome>;
|
|
31
|
+
/**
|
|
32
|
+
* Subscribes this browser (permission must already be granted) and stores the subscription on the server.
|
|
33
|
+
* An existing subscription for the same server key is reused; one for a rotated key is replaced.
|
|
34
|
+
*/
|
|
35
|
+
export declare function subscribe(api: WebPushServerApi, options?: PushHelperOptions): Promise<PushSubscription>;
|
|
36
|
+
/**
|
|
37
|
+
* Removes this browser's subscription — on the server first, so a failed call leaves both sides subscribed
|
|
38
|
+
* rather than the server holding a dead subscription. Returns `false` when there was none.
|
|
39
|
+
*/
|
|
40
|
+
export declare function unsubscribe(api: WebPushServerApi, options?: PushHelperOptions): Promise<boolean>;
|
|
41
|
+
/** Whether this browser currently holds a push subscription with notification permission granted. */
|
|
42
|
+
export declare function isSubscribed(options?: PushHelperOptions): Promise<boolean>;
|
|
43
|
+
/**
|
|
44
|
+
* Call once on app start. When the user has granted permission, re-sends (or re-creates) the subscription so
|
|
45
|
+
* the server's copy heals after the push service rotated it, the server key changed, or a cleanup deleted
|
|
46
|
+
* it — and so its last-seen time stays fresh. Never prompts. Returns whether a subscription was refreshed.
|
|
47
|
+
*/
|
|
48
|
+
export declare function refreshOnStart(api: WebPushServerApi, options?: PushHelperOptions): Promise<boolean>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// The browser half of Elarion Web Push (ADR-0076): detect whether this browser can receive pushes at all,
|
|
2
|
+
// ask for permission inside the user gesture, and keep the server's copy of the subscription in step with
|
|
3
|
+
// the browser's. Server calls go through a pluggable WebPushServerApi, so the same helpers work against
|
|
4
|
+
// `MapElarionWebPush()` or an application's own `webPush.*` handlers via the generated JSON-RPC client.
|
|
5
|
+
//
|
|
6
|
+
// The service-worker half (showing the notification, handling its click, and re-subscribing when the push
|
|
7
|
+
// service rotates a subscription) is the `/sw` sub-export.
|
|
8
|
+
import { toSubscriptionJson } from './api.js';
|
|
9
|
+
import { isSameKey, urlBase64ToUint8Array } from './base64url.js';
|
|
10
|
+
export { fetchWebPushApi, toSubscriptionJson, } from './api.js';
|
|
11
|
+
export { urlBase64ToUint8Array } from './base64url.js';
|
|
12
|
+
/** Detects push support; see {@link PushAvailability}. Safe to call during rendering. */
|
|
13
|
+
export function pushAvailability() {
|
|
14
|
+
if (typeof navigator === 'undefined' || typeof window === 'undefined')
|
|
15
|
+
return 'unsupported';
|
|
16
|
+
if ('serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window)
|
|
17
|
+
return 'available';
|
|
18
|
+
const ios = iosVersion();
|
|
19
|
+
if (ios === undefined || isStandalone())
|
|
20
|
+
return 'unsupported';
|
|
21
|
+
// Web Push reached iOS/iPadOS in 16.4, and only for Home Screen apps.
|
|
22
|
+
return ios[0] > 16 || (ios[0] === 16 && ios[1] >= 4) ? 'install-first' : 'unsupported';
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Asks for notification permission and subscribes. Call it directly from a click handler: the permission
|
|
26
|
+
* request is issued before the first `await`, because Safari only honours one made synchronously inside the
|
|
27
|
+
* user gesture.
|
|
28
|
+
*/
|
|
29
|
+
export function enablePush(api, options = {}) {
|
|
30
|
+
const availability = pushAvailability();
|
|
31
|
+
if (availability !== 'available')
|
|
32
|
+
return Promise.resolve(availability);
|
|
33
|
+
const permission = Notification.permission === 'granted' ? Promise.resolve('granted') : Notification.requestPermission();
|
|
34
|
+
return permission.then(async (result) => {
|
|
35
|
+
if (result === 'denied')
|
|
36
|
+
return 'denied';
|
|
37
|
+
if (result !== 'granted')
|
|
38
|
+
return 'dismissed';
|
|
39
|
+
await subscribe(api, options);
|
|
40
|
+
return 'subscribed';
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Subscribes this browser (permission must already be granted) and stores the subscription on the server.
|
|
45
|
+
* An existing subscription for the same server key is reused; one for a rotated key is replaced.
|
|
46
|
+
*/
|
|
47
|
+
export async function subscribe(api, options = {}) {
|
|
48
|
+
const registration = await resolveRegistration(options);
|
|
49
|
+
const key = urlBase64ToUint8Array(await api.getPublicKey());
|
|
50
|
+
let subscription = await registration.pushManager.getSubscription();
|
|
51
|
+
if (subscription && !isSameKey(subscription.options.applicationServerKey, key)) {
|
|
52
|
+
await subscription.unsubscribe();
|
|
53
|
+
subscription = null;
|
|
54
|
+
}
|
|
55
|
+
subscription ??= await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key });
|
|
56
|
+
await api.subscribe(toSubscriptionJson(subscription));
|
|
57
|
+
return subscription;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Removes this browser's subscription — on the server first, so a failed call leaves both sides subscribed
|
|
61
|
+
* rather than the server holding a dead subscription. Returns `false` when there was none.
|
|
62
|
+
*/
|
|
63
|
+
export async function unsubscribe(api, options = {}) {
|
|
64
|
+
if (pushAvailability() !== 'available')
|
|
65
|
+
return false;
|
|
66
|
+
const registration = await resolveRegistration(options);
|
|
67
|
+
const subscription = await registration.pushManager.getSubscription();
|
|
68
|
+
if (!subscription)
|
|
69
|
+
return false;
|
|
70
|
+
await api.unsubscribe(subscription.endpoint);
|
|
71
|
+
await subscription.unsubscribe();
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
/** Whether this browser currently holds a push subscription with notification permission granted. */
|
|
75
|
+
export async function isSubscribed(options = {}) {
|
|
76
|
+
if (pushAvailability() !== 'available' || Notification.permission !== 'granted')
|
|
77
|
+
return false;
|
|
78
|
+
const registration = await resolveRegistration(options);
|
|
79
|
+
return (await registration.pushManager.getSubscription()) !== null;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Call once on app start. When the user has granted permission, re-sends (or re-creates) the subscription so
|
|
83
|
+
* the server's copy heals after the push service rotated it, the server key changed, or a cleanup deleted
|
|
84
|
+
* it — and so its last-seen time stays fresh. Never prompts. Returns whether a subscription was refreshed.
|
|
85
|
+
*/
|
|
86
|
+
export async function refreshOnStart(api, options = {}) {
|
|
87
|
+
if (pushAvailability() !== 'available' || Notification.permission !== 'granted')
|
|
88
|
+
return false;
|
|
89
|
+
await subscribe(api, options);
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
async function resolveRegistration(options) {
|
|
93
|
+
return options.registration ?? navigator.serviceWorker.ready;
|
|
94
|
+
}
|
|
95
|
+
function iosVersion() {
|
|
96
|
+
const userAgent = navigator.userAgent;
|
|
97
|
+
const match = /(?:iPhone|iPad|iPod).* OS (\d+)_(\d+)/.exec(userAgent);
|
|
98
|
+
if (match)
|
|
99
|
+
return [Number(match[1]), Number(match[2])];
|
|
100
|
+
// iPadOS 13+ Safari presents a desktop Mac user agent; a touch-capable "Mac" is an iPad.
|
|
101
|
+
if (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1) {
|
|
102
|
+
const version = /Version\/(\d+)\.(\d+)/.exec(userAgent);
|
|
103
|
+
return version ? [Number(version[1]), Number(version[2])] : undefined;
|
|
104
|
+
}
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
function isStandalone() {
|
|
108
|
+
return (navigator.standalone === true ||
|
|
109
|
+
(typeof window.matchMedia === 'function' && window.matchMedia('(display-mode: standalone)').matches));
|
|
110
|
+
}
|
package/dist/sw.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type WebPushServerApi } from './api.js';
|
|
2
|
+
/** The payload `IWebPushSender` sends. */
|
|
3
|
+
export interface WebPushPayload {
|
|
4
|
+
title: string;
|
|
5
|
+
body: string;
|
|
6
|
+
url?: string;
|
|
7
|
+
tag?: string;
|
|
8
|
+
}
|
|
9
|
+
/** Options for {@link registerWebPushHandlers}. */
|
|
10
|
+
export interface WebPushServiceWorkerOptions {
|
|
11
|
+
/**
|
|
12
|
+
* Where a `pushsubscriptionchange` re-subscription is sent. Defaults to `fetchWebPushApi()` (the
|
|
13
|
+
* `MapElarionWebPush` endpoints, authenticated by same-origin cookies). A bearer-token app, which has no
|
|
14
|
+
* token in the worker, can rely on the page's `refreshOnStart` instead and pass `null` to skip it.
|
|
15
|
+
*/
|
|
16
|
+
api?: WebPushServerApi | null;
|
|
17
|
+
/** The notification icon URL. */
|
|
18
|
+
icon?: string;
|
|
19
|
+
/** The monochrome status-bar badge URL (Android). */
|
|
20
|
+
badge?: string;
|
|
21
|
+
/** Where a click navigates when the payload has no `url`. Defaults to the service worker's scope. */
|
|
22
|
+
defaultUrl?: string;
|
|
23
|
+
/** The title shown when a push carries no parseable payload (its text becomes the body). Defaults to 'New notification'. */
|
|
24
|
+
fallbackTitle?: string;
|
|
25
|
+
/** Adds or overrides notification options per payload (actions, vibration, `requireInteraction`, …). */
|
|
26
|
+
notificationOptions?: (payload: WebPushPayload) => NotificationOptions;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The parts of `ServiceWorkerGlobalScope` the handlers use — structural, so the real `self` fits without this
|
|
30
|
+
* package depending on the WebWorker type library.
|
|
31
|
+
*/
|
|
32
|
+
export interface WebPushServiceWorkerScope {
|
|
33
|
+
addEventListener(type: string, listener: (event: any) => void): void;
|
|
34
|
+
readonly registration: {
|
|
35
|
+
readonly scope: string;
|
|
36
|
+
readonly pushManager: PushManager;
|
|
37
|
+
showNotification(title: string, options?: NotificationOptions): Promise<void>;
|
|
38
|
+
};
|
|
39
|
+
readonly clients: {
|
|
40
|
+
matchAll(options?: {
|
|
41
|
+
type?: 'window';
|
|
42
|
+
includeUncontrolled?: boolean;
|
|
43
|
+
}): Promise<ReadonlyArray<{
|
|
44
|
+
readonly url: string;
|
|
45
|
+
}>>;
|
|
46
|
+
openWindow(url: string): Promise<unknown>;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** Registers the `push`, `notificationclick`, and `pushsubscriptionchange` handlers on `scope`. */
|
|
50
|
+
export declare function registerWebPushHandlers(scope: WebPushServiceWorkerScope, options?: WebPushServiceWorkerOptions): void;
|
package/dist/sw.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// The service-worker half of Elarion Web Push (ADR-0076). Import it into the application's service worker —
|
|
2
|
+
// through its bundler (e.g. vite-plugin-pwa's injectManifest) or a `type: 'module'` registration — and call
|
|
3
|
+
// registerWebPushHandlers(self) once at the top level:
|
|
4
|
+
//
|
|
5
|
+
// import { registerWebPushHandlers } from '@swimmesberger/elarion-webpush/sw'
|
|
6
|
+
// registerWebPushHandlers(self, { icon: '/icons/192.png' })
|
|
7
|
+
//
|
|
8
|
+
// It shows the `{title, body, url, tag}` payload IWebPushSender sends, focuses or opens the app at the
|
|
9
|
+
// payload's URL on click, and re-subscribes when the push service rotates a subscription.
|
|
10
|
+
import { fetchWebPushApi, toSubscriptionJson } from './api.js';
|
|
11
|
+
import { urlBase64ToUint8Array } from './base64url.js';
|
|
12
|
+
/** Registers the `push`, `notificationclick`, and `pushsubscriptionchange` handlers on `scope`. */
|
|
13
|
+
export function registerWebPushHandlers(scope, options = {}) {
|
|
14
|
+
const api = options.api === undefined ? fetchWebPushApi() : options.api;
|
|
15
|
+
scope.addEventListener('push', (event) => {
|
|
16
|
+
const payload = parsePayload(event.data?.text(), options);
|
|
17
|
+
// Always show something: a push with userVisibleOnly that displays nothing is penalized by the browser.
|
|
18
|
+
event.waitUntil(scope.registration.showNotification(payload.title, {
|
|
19
|
+
body: payload.body,
|
|
20
|
+
tag: payload.tag,
|
|
21
|
+
icon: options.icon,
|
|
22
|
+
badge: options.badge,
|
|
23
|
+
data: { url: payload.url },
|
|
24
|
+
...options.notificationOptions?.(payload),
|
|
25
|
+
}));
|
|
26
|
+
});
|
|
27
|
+
scope.addEventListener('notificationclick', (event) => {
|
|
28
|
+
event.notification.close();
|
|
29
|
+
const data = event.notification.data;
|
|
30
|
+
const relative = typeof data?.url === 'string' ? data.url : (options.defaultUrl ?? scope.registration.scope);
|
|
31
|
+
event.waitUntil(focusOrOpen(scope, new URL(relative, scope.registration.scope).href));
|
|
32
|
+
});
|
|
33
|
+
scope.addEventListener('pushsubscriptionchange', (event) => {
|
|
34
|
+
if (!api)
|
|
35
|
+
return;
|
|
36
|
+
event.waitUntil(resubscribe(scope, api, event).catch(() => {
|
|
37
|
+
// Nothing else to do from the worker; the page's refreshOnStart heals it on the next visit.
|
|
38
|
+
}));
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
function parsePayload(text, options) {
|
|
42
|
+
if (text) {
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(text);
|
|
45
|
+
if (typeof parsed.title === 'string') {
|
|
46
|
+
return {
|
|
47
|
+
title: parsed.title,
|
|
48
|
+
body: typeof parsed.body === 'string' ? parsed.body : '',
|
|
49
|
+
url: typeof parsed.url === 'string' ? parsed.url : undefined,
|
|
50
|
+
tag: typeof parsed.tag === 'string' ? parsed.tag : undefined,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Not our JSON payload — show it as plain text below.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return { title: options.fallbackTitle ?? 'New notification', body: text ?? '' };
|
|
59
|
+
}
|
|
60
|
+
async function focusOrOpen(scope, url) {
|
|
61
|
+
// type: 'window' makes every match a WindowClient.
|
|
62
|
+
const windows = (await scope.clients.matchAll({ type: 'window', includeUncontrolled: true }));
|
|
63
|
+
const exact = windows.find((client) => client.url === url);
|
|
64
|
+
if (exact) {
|
|
65
|
+
await exact.focus();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
// Prefer reusing an open window of the app (one installed-PWA window) over opening a second one.
|
|
69
|
+
const same = windows.find((client) => new URL(client.url).origin === new URL(url).origin && client.navigate);
|
|
70
|
+
if (same?.navigate) {
|
|
71
|
+
try {
|
|
72
|
+
await same.navigate(url);
|
|
73
|
+
await same.focus();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// An uncontrolled client cannot be navigated; fall through to a new window.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
await scope.clients.openWindow(url);
|
|
81
|
+
}
|
|
82
|
+
async function resubscribe(scope, api, event) {
|
|
83
|
+
const old = event.oldSubscription ?? null;
|
|
84
|
+
const replacement = event.newSubscription ??
|
|
85
|
+
(await scope.registration.pushManager.subscribe({
|
|
86
|
+
userVisibleOnly: true,
|
|
87
|
+
applicationServerKey: old?.options.applicationServerKey ?? urlBase64ToUint8Array(await api.getPublicKey()),
|
|
88
|
+
}));
|
|
89
|
+
await api.subscribe(toSubscriptionJson(replacement));
|
|
90
|
+
if (old && old.endpoint !== replacement.endpoint)
|
|
91
|
+
await api.unsubscribe(old.endpoint).catch(() => undefined);
|
|
92
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@swimmesberger/elarion-webpush",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "The browser half of Elarion Web Push: push availability detection (including the iOS Home Screen requirement), gesture-safe permission and subscribe/unsubscribe/refresh helpers over a pluggable server API, and a service-worker module for push, notificationclick, and pushsubscriptionchange.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"import": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"./sw": {
|
|
13
|
+
"types": "./dist/sw.d.ts",
|
|
14
|
+
"import": "./dist/sw.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"sideEffects": false,
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc -p tsconfig.json",
|
|
24
|
+
"typecheck": "tsc --noEmit -p tsconfig.test.json && tsc --noEmit -p tsconfig.sw-test.json",
|
|
25
|
+
"test": "npm run typecheck && vitest run"
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/swimmesberger/Elarion.git",
|
|
30
|
+
"directory": "src/elarion-webpush"
|
|
31
|
+
},
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/swimmesberger/Elarion/issues"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://github.com/swimmesberger/Elarion#readme",
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public",
|
|
38
|
+
"registry": "https://registry.npmjs.org/"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=20.11"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"typescript": "^5.9.3",
|
|
45
|
+
"vitest": "^4.0.18"
|
|
46
|
+
}
|
|
47
|
+
}
|