mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-04 13:03:37 +00:00
feat(notifications): onPush and enable with Web Push on the app's own origin
puter.notifications.onPush(key, handler) handles an app's notifications for one channel: live over the events socket while the app is connected, and as a browser push otherwise. enable() asks for notification permission on the site's origin and subscribes through the site's worker, which imports push-sw.js (served at js.puter.com/v2/push-sw.js). A new subscription is confirmed by a challenge push the worker hands back to the page. disable() removes it. The GUI uses the same flow through /sw.js when the server publishes pushWebPushKey.
This commit is contained in:
1 parent
3b4945f018
commit
40253ae12b
26 files changed
+1733
-14
No files matched your search
+13
-1
@@ -10,7 +10,8 @@ Core does not subscribe the browser to a push service, and it has no `/push/*` r
|
||||
|
||||
```ts
|
||||
{ uid?: string; title?: string; body?: string; icon?: string; group?: string;
|
||||
target?: { app?: string; path?: string }; data?: unknown }
|
||||
target?: { app?: string; path?: string }; data?: unknown; channel?: string;
|
||||
type?: 'challenge'; challenge?: string }
|
||||
```
|
||||
|
||||
`uid` is the notification's uid, a later push with the same `group` replaces the one showing, and `target` and `data` reach the app when the user taps it.
|
||||
@@ -31,6 +32,17 @@ service_script(async (api) => {
|
||||
|
||||
With no client registered, the tab says push notifications aren't available in this browser.
|
||||
|
||||
### Built-in Web Push client
|
||||
|
||||
When the server also sets the `pushWebPushKey` gui param (a VAPID public key), core registers its own client ([webPushClient.js](../src/gui/src/helpers/webPushClient.js)) unless an extension registered one first. It uses `puter.notifications.enable()` / `disable()`, the same standard Web Push flow apps use on their own origins:
|
||||
|
||||
1. `GET /push/web/key` for the key, then `pushManager.subscribe()` on the `/sw.js` registration.
|
||||
2. `POST /push/web/subscriptions { subscription }`. A subscription the server already has answers `{ status: 'active' }`. A new one answers `{ status: 'pending' }`, and the server sends it a push with `{ type: 'challenge', challenge }`.
|
||||
3. The worker shows that push and posts `{ type: 'puter-push-challenge', challenge }` to the origin's windows; the page sends it to `POST /push/web/subscriptions/verify`. Only then is the device registered, so a subscription proves the user allowed notifications in that browser.
|
||||
4. `DELETE /push/web/subscriptions { endpoint }` removes it.
|
||||
|
||||
Apps on their own origin do the same through `push-sw.js`, which they import into their own worker. A subscription made with an app token is the app's: its pushes are that app's notifications only.
|
||||
|
||||
## Interface
|
||||
|
||||
```ts
|
||||
|
||||
@@ -151,6 +151,10 @@ describe('StaticAssetsController client_libs_root', () => {
|
||||
|
||||
const sentPutility = await callGetHandler(router, '/putility/v1');
|
||||
expect(sentPutility).toEqual({ filename: 'putility.js/v1.js', root });
|
||||
|
||||
expect(findRoute(router, 'get', '/v2/push-sw.js')?.options.subdomain).toBe('js');
|
||||
const sentWorker = await callGetHandler(router, '/v2/push-sw.js');
|
||||
expect(sentWorker).toEqual({ filename: 'puter.js/push-sw.js', root });
|
||||
});
|
||||
|
||||
it('does not register the client_libs routes when the root is unset', () => {
|
||||
@@ -186,6 +190,9 @@ describe('StaticAssetsController puterjs_root', () => {
|
||||
expect(v1.root).toBe(root);
|
||||
const bareV1 = await callGetHandler(router, '/v1');
|
||||
expect(bareV1.filename).toBe('puter.js');
|
||||
|
||||
const worker = await callGetHandler(router, '/v2/push-sw.js');
|
||||
expect(worker).toEqual({ filename: 'push-sw.js', root });
|
||||
});
|
||||
|
||||
it('serves puter.dev.js when present (dev webpack build)', async () => {
|
||||
|
||||
@@ -26,9 +26,9 @@ import type { PuterRouter } from '../../core/http/PuterRouter';
|
||||
/**
|
||||
* Static asset routes.
|
||||
*
|
||||
* /puter.js/v1, /puter.js/v2 → any subdomain /v1, /v2, /putility/v1 → js
|
||||
* subdomain /sdk/* → root subdomain — puter-js bundle /dist/_, /src/_,
|
||||
* /assets/* → root subdomain
|
||||
* /puter.js/v1, /puter.js/v2 → any subdomain /v1, /v2, /v2/push-sw.js,
|
||||
* /putility/v1 → js subdomain /sdk/* → root subdomain — puter-js bundle
|
||||
* /dist/_, /src/_, /assets/* → root subdomain
|
||||
*
|
||||
* Each block depends on its config root (`client_libs_root`, `gui_assets_root`,
|
||||
* `puterjs_root`). When unset, that block is skipped — deployments that don't
|
||||
@@ -55,6 +55,9 @@ export class StaticAssetsController extends PuterController {
|
||||
router.get('/putility/v1', { subdomain: 'js' }, (_req, res) => {
|
||||
res.sendFile('putility.js/v1.js', { root });
|
||||
});
|
||||
router.get('/v2/push-sw.js', { subdomain: 'js' }, (_req, res) => {
|
||||
res.sendFile('puter.js/push-sw.js', { root });
|
||||
});
|
||||
}
|
||||
|
||||
// puter-js SDK mount. GUI loads it at `/sdk/puter.dev.js`; the
|
||||
@@ -100,6 +103,13 @@ export class StaticAssetsController extends PuterController {
|
||||
router.get('/v2', { subdomain: 'js' }, (_req, res) => {
|
||||
res.sendFile(puterJsFile, { root: puterjsRoot });
|
||||
});
|
||||
router.get(
|
||||
'/v2/push-sw.js',
|
||||
{ subdomain: 'js' },
|
||||
(_req, res) => {
|
||||
res.sendFile('push-sw.js', { root: puterjsRoot });
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -408,7 +408,8 @@ export class EventForwardService extends PuterService {
|
||||
* peer region. Only this node's sockets count where presence is not kept.
|
||||
*/
|
||||
async isOnline(userId: number, appUid: string | null): Promise<boolean> {
|
||||
if (this.services.socket.has(forwardTarget(userId, appUid))) return true;
|
||||
if (this.services.socket.has(forwardTarget(userId, appUid)))
|
||||
return true;
|
||||
if (!this.active) return false;
|
||||
if (await this.heldInRegion(userId, appUid)) return true;
|
||||
return (await this.regionsFor(userId, appUid)).length > 0;
|
||||
|
||||
@@ -16,13 +16,12 @@ await puter.notifications.notify({
|
||||
|
||||
## Where a notification shows
|
||||
|
||||
A notification always shows in the user's Puter notifications panel. While your app is open, it also reaches the app through [`onNotification()`](/Notifications/onNotification/).
|
||||
A notification always shows in the user's Puter notifications panel. Puter then routes it one of two ways:
|
||||
|
||||
It also goes out as a push notification when all of these are true:
|
||||
- **Live**, while your app is open and connected: it reaches the app through [`onNotification()`](/Notifications/onNotification/) and [`onPush()`](/Notifications/onPush/).
|
||||
- **As a push**, while it isn't: to the browsers where the user turned pushes on for your app.
|
||||
|
||||
- The user has neither Puter nor your app open.
|
||||
- The user allowed your app to send pushes. Ask with [`requestPermission()`](/Notifications/requestPermission/).
|
||||
- The user has turned on push notifications on at least one device. Allowing your app in the permission prompt turns them on in that browser too, and [`enablePush()`](/Notifications/enablePush/) offers to turn them on in the browser your app is in.
|
||||
For a push, the user has to have turned them on. On a website, call [`enable()`](/Notifications/enable/) from a click and handle pushes with [`onPush()`](/Notifications/onPush/); they arrive on your own origin through a small service worker. Inside the Puter desktop, pushes go through Puter: ask with [`requestPermission()`](/Notifications/requestPermission/), and [`enablePush()`](/Notifications/enablePush/) offers to turn them on in the browser.
|
||||
|
||||
A user gets at most one push a minute from each app. A notification over that still shows in the panel; it's just not pushed.
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
title: puter.notifications.disable()
|
||||
description: Turn off push notifications for your app in this browser.
|
||||
platforms: [websites]
|
||||
---
|
||||
|
||||
Ends this browser's push subscription for your app and removes it from Puter. Notifications still reach your app live while it's open. The browser's permission isn't changed, so a later [`enable()`](/Notifications/enable/) turns pushes back on without a prompt.
|
||||
|
||||
## Syntax
|
||||
```js
|
||||
puter.notifications.disable()
|
||||
puter.notifications.disable(options)
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
#### `options` (Object) (optional)
|
||||
- `serviceWorker` (String): path of the worker passed to `enable()`. Default `/puter-sw.js`.
|
||||
|
||||
## Return value
|
||||
A `Promise` that resolves to `true` when a subscription was removed, and `false` when there was none.
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
title: puter.notifications.enable()
|
||||
description: Turn on push notifications for your app in this browser.
|
||||
platforms: [websites, apps]
|
||||
---
|
||||
|
||||
Turns on push notifications for your app in the browser the user is in. Call it from a click: it asks the browser's permission first, before anything else, because browsers only show that prompt from a user gesture.
|
||||
|
||||
On a website, the browser asks on your own origin, and pushes arrive through your service worker (see [`onPush()`](/Notifications/onPush/)). Puter then sends a confirmation push to that browser and only keeps the subscription once your page hands it back, so a subscription can only come from a browser where the user said yes. Allowing it also lets your app's notifications go out as pushes, which the user can revoke in Puter's settings.
|
||||
|
||||
Inside the Puter desktop it does what [`enablePush()`](/Notifications/enablePush/) does.
|
||||
|
||||
Once enabled, [`onPush()`](/Notifications/onPush/) keeps the subscription current on later visits without asking again.
|
||||
|
||||
## Syntax
|
||||
```js
|
||||
puter.notifications.enable()
|
||||
puter.notifications.enable(options)
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
#### `options` (Object) (optional)
|
||||
- `serviceWorker` (String): path of the worker that imports `push-sw.js`. Default `/puter-sw.js`.
|
||||
|
||||
## Return value
|
||||
A `Promise` that resolves to `true` when this browser receives pushes for your app, and `false` when the user didn't allow notifications or the browser can't receive pushes. It rejects with `push_unavailable` when the server has no push service, `push_worker_unavailable` when the service worker can't be registered, and `push_verification_timeout` when the confirmation push didn't arrive.
|
||||
|
||||
On a website with nobody signed in, it opens the sign-in after the browser's prompt. Signing the user in first avoids a blocked popup.
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
title: puter.notifications.onPush()
|
||||
description: Handle your app's notifications for one key, live while the app is open and as browser pushes while it isn't.
|
||||
platforms: [websites, apps]
|
||||
---
|
||||
|
||||
Handles the notifications your app sends with `channel: key` in [`notify()`](/Notifications/notify/). While your app is open and connected, the handler runs in the page as each one arrives. While it isn't, Puter sends a browser push instead, and the handler runs in your site's service worker to decide what the notification shows.
|
||||
|
||||
Puter picks the route: a notification never goes both ways. The user turns pushes on once with [`enable()`](/Notifications/enable/); after that, `onPush()` keeps this browser's subscription current on every page load without asking again. Different keys share that one permission.
|
||||
|
||||
On a website, pushes reach your own origin, so your site has to serve a service worker that loads Puter's push script. Put this in `/puter-sw.js` at the root of your site:
|
||||
|
||||
```js
|
||||
importScripts('https://js.puter.com/v2/push-sw.js');
|
||||
```
|
||||
|
||||
If your site already has a service worker, add that line to it instead and pass its path as `serviceWorker`. Inside the Puter desktop, pushes go through Puter itself and no worker is needed.
|
||||
|
||||
## Syntax
|
||||
```js
|
||||
puter.notifications.onPush(key, handler)
|
||||
puter.notifications.onPush(key, handler, options)
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
#### `key` (String) (required)
|
||||
The `channel` your notifications are sent with: 1 to 32 lowercase letters, digits or `-`.
|
||||
|
||||
#### `handler` (Function) (required)
|
||||
Called with the notification (the same shape [`onNotification()`](/Notifications/onNotification/) delivers). It may return, or resolve to, an object that changes what the browser shows:
|
||||
|
||||
- `title`, `body`, `icon`, `badge`, `image`, `tag`: strings, as in the browser's `showNotification()`.
|
||||
- `url`: a path or URL on your origin, opened when the notification is tapped. Defaults to the notification's `target`. Other origins are replaced with your site's root.
|
||||
|
||||
Returning nothing shows the notification's own `title` and `text`. In the page, the result is only shown when the tab is hidden.
|
||||
|
||||
#### `options` (Object) (optional)
|
||||
- `serviceWorker` (String): path of the worker that imports `push-sw.js`. Default `/puter-sw.js`.
|
||||
- `onError` (Function): called when the handler throws, the live listener stops, or keeping the subscription current fails. Without it, errors go to the console.
|
||||
|
||||
## Handler limits
|
||||
|
||||
On a website, the handler is saved as source and run again in your service worker when a push arrives with no page open. There it follows the Web Push rules:
|
||||
|
||||
- **Self-contained.** It is re-created from its source, so it can't use variables, functions or imports from around it. Everything it needs must come from its argument or be written inside it.
|
||||
- **No page.** It runs in a service worker: no `window`, `document` or `localStorage`. `fetch`, `indexedDB` and `caches` are available.
|
||||
- **Fast.** It has 5 seconds. After that, or if it throws, the notification shows with its own title and text.
|
||||
- **Something always shows.** Browsers require every push to show a notification, so a handler can change what shows but can't hide it.
|
||||
- **Small.** A push carries about 4 KB, and `data` at most 1 KB. Send IDs and fetch the rest if you need it.
|
||||
- **`eval`.** It is re-created with `new Function`, so if your worker is served with a Content-Security-Policy it must allow `'unsafe-eval'`.
|
||||
|
||||
A handler that needs more, such as imported code, can be registered in the worker itself, where it wins over the saved one:
|
||||
|
||||
```js
|
||||
importScripts('https://js.puter.com/v2/push-sw.js', '/my-formatting.js');
|
||||
puterPush.onPush('chat', (n) => ({ title: formatTitle(n) }));
|
||||
```
|
||||
|
||||
## Return value
|
||||
A `Promise` that resolves to `{ off }`. `off()` stops the handler in this page and, when no other handler for `key` is left, removes the saved one.
|
||||
|
||||
## Examples
|
||||
|
||||
<strong class="example-title">Chat messages</strong>
|
||||
|
||||
```html
|
||||
<html>
|
||||
<body>
|
||||
<script src="https://js.puter.com/v2/"></script>
|
||||
<button id="enable">Turn on notifications</button>
|
||||
<script>
|
||||
puter.notifications.onPush('chat', (n) => ({
|
||||
title: `${n.app?.name ?? 'Chat'}: ${n.title}`,
|
||||
url: n.target ?? '/',
|
||||
}));
|
||||
|
||||
document.getElementById('enable').addEventListener('click', async () => {
|
||||
const on = await puter.notifications.enable();
|
||||
puter.print(on ? 'Notifications are on<br>' : 'Notifications are off<br>');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
@@ -550,6 +550,30 @@ let sidebar = [
|
||||
source: '/Notifications/onNotification.md',
|
||||
path: '/Notifications/onNotification',
|
||||
},
|
||||
{
|
||||
title: '<code>onPush()</code>',
|
||||
page_title: '<code>puter.notifications.onPush()</code>',
|
||||
title_tag: 'puter.notifications.onPush()',
|
||||
icon: '/assets/img/function.svg',
|
||||
source: '/Notifications/onPush.md',
|
||||
path: '/Notifications/onPush',
|
||||
},
|
||||
{
|
||||
title: '<code>enable()</code>',
|
||||
page_title: '<code>puter.notifications.enable()</code>',
|
||||
title_tag: 'puter.notifications.enable()',
|
||||
icon: '/assets/img/function.svg',
|
||||
source: '/Notifications/enable.md',
|
||||
path: '/Notifications/enable',
|
||||
},
|
||||
{
|
||||
title: '<code>disable()</code>',
|
||||
page_title: '<code>puter.notifications.disable()</code>',
|
||||
title_tag: 'puter.notifications.disable()',
|
||||
icon: '/assets/img/function.svg',
|
||||
source: '/Notifications/disable.md',
|
||||
path: '/Notifications/disable',
|
||||
},
|
||||
{
|
||||
title: "<code>on('click')</code>",
|
||||
page_title: "<code>puter.notifications.on('click')</code>",
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
PUSH_TAP_PARAM,
|
||||
} from './notificationTap.js';
|
||||
import { clear_query_param } from './parseSharedPath.js';
|
||||
import { getPushClient, registerPushClient } from './pushClient.js';
|
||||
import { webPushClient } from './webPushClient.js';
|
||||
|
||||
export const WORKER_URL = '/sw.js';
|
||||
export const WORKER_CLICK_MESSAGE = 'puter-notification-click';
|
||||
@@ -151,6 +153,9 @@ export const startPushWorker = async ({
|
||||
|
||||
// Leave an existing worker: removing it ends this browser's subscription.
|
||||
if ( ! pushWorkerEnabled(params, nav) ) return;
|
||||
if ( typeof params.pushWebPushKey === 'string' && params.pushWebPushKey && ! getPushClient() ) {
|
||||
registerPushClient(webPushClient());
|
||||
}
|
||||
nav.serviceWorker.addEventListener('message', (event) => {
|
||||
if ( event.data?.type !== WORKER_CLICK_MESSAGE || ! normalizePushTap(event.data.notification) ) return;
|
||||
event.source?.postMessage?.({ type: WORKER_CLICK_ACK_MESSAGE, id: event.data.id });
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// Puter's own push client: standard Web Push through `/sw.js`, via
|
||||
// `puter.notifications`. Used when the server publishes `pushWebPushKey`.
|
||||
|
||||
/** @returns {import('./pushClient.js').PushClient} */
|
||||
export const webPushClient = ({
|
||||
notifications = globalThis.puter?.notifications,
|
||||
NotificationApi = globalThis.Notification,
|
||||
nav = globalThis.navigator,
|
||||
} = {}) => {
|
||||
const currentDevice = async () => {
|
||||
const devices = await notifications.listDevices();
|
||||
return devices.find((d) => d.current && d.platform === 'webpush') ?? null;
|
||||
};
|
||||
return {
|
||||
// Not async before `enable()`: it asks the browser inside the click.
|
||||
enable: () => notifications.enable().then(async (on) => {
|
||||
if ( ! on ) throw new Error(`notifications ${NotificationApi?.permission ?? 'unavailable'}`);
|
||||
const device = await currentDevice();
|
||||
if ( ! device ) throw new Error('this browser was not registered');
|
||||
return { deviceUid: device.uid };
|
||||
}),
|
||||
disable: async () => {
|
||||
await notifications.disable();
|
||||
},
|
||||
status: async () => {
|
||||
const permission = NotificationApi?.permission ?? 'default';
|
||||
const registration = await nav.serviceWorker?.getRegistration('/');
|
||||
const subscription = await registration?.pushManager?.getSubscription();
|
||||
if ( permission !== 'granted' || ! subscription ) return { permission, registered: false };
|
||||
const device = await currentDevice();
|
||||
return device
|
||||
? { permission, registered: true, deviceUid: device.uid }
|
||||
: { permission, registered: false };
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { webPushClient } from './webPushClient.js';
|
||||
|
||||
const make = ({ on = true, devices = [], permission = 'granted', subscription = {} } = {}) => {
|
||||
const notifications = {
|
||||
enable: vi.fn(async () => on),
|
||||
disable: vi.fn(async () => true),
|
||||
listDevices: vi.fn(async () => devices),
|
||||
};
|
||||
const nav = {
|
||||
serviceWorker: {
|
||||
getRegistration: vi.fn(async () => ({ pushManager: { getSubscription: async () => subscription } })),
|
||||
},
|
||||
};
|
||||
return { notifications, client: webPushClient({ notifications, NotificationApi: { permission }, nav }) };
|
||||
};
|
||||
|
||||
const current = { uid: 'd1', platform: 'webpush', current: true };
|
||||
|
||||
describe('webPushClient', () => {
|
||||
it('calls enable() before anything is awaited, and returns this browser`s device', async () => {
|
||||
const { notifications, client } = make({ devices: [{ uid: 'x', platform: 'web', current: true }, current] });
|
||||
const pending = client.enable();
|
||||
expect(notifications.enable).toHaveBeenCalledTimes(1);
|
||||
await expect(pending).resolves.toEqual({ deviceUid: 'd1' });
|
||||
});
|
||||
|
||||
it('rejects when the browser said no', async () => {
|
||||
const { client } = make({ on: false, permission: 'denied' });
|
||||
await expect(client.enable()).rejects.toThrow('notifications denied');
|
||||
});
|
||||
|
||||
it('is registered only with a subscription the server lists for this session', async () => {
|
||||
await expect(make({ devices: [current] }).client.status()).resolves.toEqual({ permission: 'granted', registered: true, deviceUid: 'd1' });
|
||||
await expect(make({ devices: [] }).client.status()).resolves.toEqual({ permission: 'granted', registered: false });
|
||||
await expect(make({ subscription: null, devices: [current] }).client.status()).resolves.toEqual({ permission: 'granted', registered: false });
|
||||
await expect(make({ permission: 'denied' }).client.status()).resolves.toEqual({ permission: 'denied', registered: false });
|
||||
});
|
||||
|
||||
it('turns off through puter.notifications', async () => {
|
||||
const { notifications, client } = make();
|
||||
await client.disable();
|
||||
expect(notifications.disable).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+18
-3
@@ -22,6 +22,9 @@
|
||||
// is { uid, title, body, icon, group, target: { app, path }, data }.
|
||||
// On `pushsubscriptionchange` it posts { type: 'puter-push-subscription-changed' }
|
||||
// to this origin's windows, so a signed-in page can register again.
|
||||
// A `type: 'challenge'` message is shown, then its `challenge` is posted to
|
||||
// this origin's windows as { type: 'puter-push-challenge', challenge } for the
|
||||
// page to confirm the subscription with the server.
|
||||
|
||||
const DEFAULT_TITLE = 'New notification';
|
||||
const DEFAULT_ICON = '/favicons/android-icon-192x192.png';
|
||||
@@ -29,6 +32,7 @@ const SHOW_MESSAGE = 'puter-show-notification';
|
||||
const CLICK_MESSAGE = 'puter-notification-click';
|
||||
const CLICK_ACK_MESSAGE = 'puter-notification-click-ack';
|
||||
const SUBSCRIPTION_CHANGED_MESSAGE = 'puter-push-subscription-changed';
|
||||
const CHALLENGE_MESSAGE = 'puter-push-challenge';
|
||||
const CLICK_PARAM = 'puter.notification';
|
||||
// Browsers allow openWindow only shortly after the click.
|
||||
const CLICK_ACK_BUDGET_MS = 600;
|
||||
@@ -192,11 +196,22 @@ async function showPageMessage (scope, message) {
|
||||
await showMessage(scope.registration, message, scope.location.origin);
|
||||
}
|
||||
|
||||
async function announceSubscriptionChange (scope) {
|
||||
async function postToWindows (scope, message) {
|
||||
const origin = scope.location.origin;
|
||||
const windowClients = await scope.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||
for ( const client of windowClients ) {
|
||||
if ( sameOriginUrl(client.url, origin) ) client.postMessage({ type: SUBSCRIPTION_CHANGED_MESSAGE });
|
||||
if ( sameOriginUrl(client.url, origin) ) client.postMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
function announceSubscriptionChange (scope) {
|
||||
return postToWindows(scope, { type: SUBSCRIPTION_CHANGED_MESSAGE });
|
||||
}
|
||||
|
||||
async function handlePush (scope, message) {
|
||||
await showMessage(scope.registration, message, scope.location.origin);
|
||||
if ( message?.type === 'challenge' && nonEmptyString(message.challenge) ) {
|
||||
await postToWindows(scope, { type: CHALLENGE_MESSAGE, challenge: message.challenge });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,7 +224,7 @@ self.addEventListener('activate', (event) => {
|
||||
});
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
event.waitUntil(showMessage(self.registration, readPushPayload(event.data), self.location.origin));
|
||||
event.waitUntil(handlePush(self, readPushPayload(event.data)));
|
||||
});
|
||||
|
||||
self.addEventListener('message', (event) => {
|
||||
|
||||
@@ -319,6 +319,27 @@ describe('service worker pushsubscriptionchange', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('service worker challenge push', () => {
|
||||
it('shows it, then hands the challenge to this origin`s windows', async () => {
|
||||
const desktop = windowClient(`${ORIGIN}/desktop`);
|
||||
const other = windowClient('https://elsewhere.example/');
|
||||
const worker = loadWorker({ windows: [desktop, other] });
|
||||
await worker.fire('push', {
|
||||
data: pushData({ puter: { type: 'challenge', challenge: 'c.sig', uid: 'n1', title: 'Notifications are on', group: 'puter-push-challenge' } }),
|
||||
});
|
||||
expect(worker.shown.map((n) => n.title)).toEqual(['Notifications are on']);
|
||||
expect(desktop.postMessage).toHaveBeenCalledWith({ type: 'puter-push-challenge', challenge: 'c.sig' });
|
||||
expect(other.postMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('posts nothing for an ordinary push', async () => {
|
||||
const desktop = windowClient(`${ORIGIN}/desktop`);
|
||||
const worker = loadWorker({ windows: [desktop] });
|
||||
await worker.fire('push', { data: pushData({ puter: { uid: 'n1', title: 'Hi' } }) });
|
||||
expect(desktop.postMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('service worker lifecycle', () => {
|
||||
it('takes over at once and adds no fetch handler', async () => {
|
||||
const worker = loadWorker();
|
||||
|
||||
Vendored
+3
@@ -224,9 +224,12 @@ export type {
|
||||
NotificationListener,
|
||||
NotificationSender,
|
||||
OnNotificationOptions,
|
||||
OnPushOptions,
|
||||
PushDevice,
|
||||
PushDeviceRegistration,
|
||||
PushHandler,
|
||||
PushPlatform,
|
||||
PushShown,
|
||||
} from './types/modules/notifications/types.js';
|
||||
|
||||
// -- puter.os --
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { removeWebPush, webPushSupported, workerFor } from './lib/webPush.js';
|
||||
|
||||
/**
|
||||
* Turns push notifications off for this app in this browser: ends the browser's
|
||||
* subscription and removes it from Puter. Notifications still arrive live while
|
||||
* the app is open.
|
||||
*
|
||||
* @this {import('./index.js').NotificationsModule}
|
||||
* @param {{ serviceWorker?: string }} [options]
|
||||
* @returns {Promise<boolean>} Whether there was a subscription to remove.
|
||||
*/
|
||||
export async function disable(options = {}) {
|
||||
const { puter } = this;
|
||||
if (puter.env !== 'web' && puter.env !== 'gui') return false;
|
||||
if (!webPushSupported()) return false;
|
||||
return await removeWebPush(puter, {
|
||||
worker: workerFor(puter.env, options.serviceWorker),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { needsSignIn, signInVisitor } from '../events/lib/signIn.js';
|
||||
import { syncWebPush, webPushSupported, workerFor } from './lib/webPush.js';
|
||||
|
||||
/**
|
||||
* Turns on push notifications for this app in this browser. Call it from a
|
||||
* click: the browser's prompt is asked before anything else.
|
||||
*
|
||||
* Inside the Puter desktop this is `enablePush()`. On a website it asks for
|
||||
* notification permission on the site's own origin, then subscribes through the
|
||||
* site's service worker (`/puter-sw.js` unless `serviceWorker` names another)
|
||||
* and confirms the subscription with Puter.
|
||||
*
|
||||
* @this {import('./index.js').NotificationsModule}
|
||||
* @param {{ serviceWorker?: string }} [options]
|
||||
* @returns {Promise<boolean>} Whether this browser now receives pushes.
|
||||
*/
|
||||
export async function enable(options = {}) {
|
||||
const { puter } = this;
|
||||
if (puter.env === 'app') return this.enablePush();
|
||||
if (!webPushSupported()) return false;
|
||||
|
||||
// Before any await, so the click's activation covers the prompt.
|
||||
const asking =
|
||||
Notification.permission === 'default'
|
||||
? Notification.requestPermission()
|
||||
: Promise.resolve(Notification.permission);
|
||||
if ((await asking) !== 'granted') return false;
|
||||
|
||||
if (needsSignIn(puter)) await signInVisitor(puter);
|
||||
await syncWebPush(puter, {
|
||||
worker: workerFor(puter.env, options.serviceWorker),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -1,15 +1,26 @@
|
||||
import EventListener from '../../lib/EventListener.js';
|
||||
import { needsSignIn } from '../events/lib/signIn.js';
|
||||
import { disable } from './disable.js';
|
||||
import { enable } from './enable.js';
|
||||
import { enablePush } from './enablePush.js';
|
||||
import { CLICKS_STORE, idbDelete, idbGet } from './lib/pushStore.js';
|
||||
import { toClick } from './lib/shapes.js';
|
||||
import { listDevices } from './listDevices.js';
|
||||
import { notify } from './notify.js';
|
||||
import { onNotification } from './onNotification.js';
|
||||
import { onPush, shownFrom } from './onPush.js';
|
||||
import { registerDevice } from './registerDevice.js';
|
||||
import { removeDevice } from './removeDevice.js';
|
||||
import { hasPermission, requestPermission } from './requestPermission.js';
|
||||
|
||||
/** The desktop's broadcast for a click on one of this app's notifications. */
|
||||
export const CLICK_BROADCAST = 'notificationClick';
|
||||
/** What `push-sw.js` posts to an open page of the app when a push is tapped. */
|
||||
export const WORKER_CLICK_MESSAGE = 'puter-notification-click';
|
||||
/** What a worker posts when the browser replaced the push subscription. */
|
||||
export const SUBSCRIPTION_CHANGED_MESSAGE = 'puter-push-subscription-changed';
|
||||
/** Query parameter `push-sw.js` adds to a page it opens for a tap. */
|
||||
export const CLICK_PARAM = 'puter.notification';
|
||||
|
||||
/**
|
||||
* An app's notifications to the user it runs for.
|
||||
@@ -20,6 +31,9 @@ export class NotificationsModule extends EventListener {
|
||||
// Rebound in the constructor so destructured calls keep `this`.
|
||||
notify = notify;
|
||||
onNotification = onNotification;
|
||||
enable = enable;
|
||||
disable = disable;
|
||||
onPush = onPush;
|
||||
requestPermission = requestPermission;
|
||||
hasPermission = hasPermission;
|
||||
enablePush = enablePush;
|
||||
@@ -28,6 +42,14 @@ export class NotificationsModule extends EventListener {
|
||||
removeDevice = removeDevice;
|
||||
|
||||
#listening = false;
|
||||
/** @type {Map<string, Set<{ handler: Function; onError?: Function }>>} */
|
||||
#pushHandlers = new Map();
|
||||
/** @type {Promise<{ off: () => Promise<void> } | null> | null} */
|
||||
#pushFeed = null;
|
||||
/** @type {(() => unknown) | null} */
|
||||
#stopWaitingForAuth = null;
|
||||
/** @type {(() => Promise<void>) | null} */
|
||||
#resync = null;
|
||||
|
||||
/** @param {import('../../index.js').Puter} puter */
|
||||
constructor(puter) {
|
||||
@@ -41,6 +63,9 @@ export class NotificationsModule extends EventListener {
|
||||
for (const name of [
|
||||
'notify',
|
||||
'onNotification',
|
||||
'enable',
|
||||
'disable',
|
||||
'onPush',
|
||||
'requestPermission',
|
||||
'hasPermission',
|
||||
'enablePush',
|
||||
@@ -76,9 +101,176 @@ export class NotificationsModule extends EventListener {
|
||||
);
|
||||
});
|
||||
replaying = false;
|
||||
if (this.puter.env === 'web') this.#listenForWorkerClicks(handler);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal `onPush`'s live side: one notifications feed for every key.
|
||||
* Resolves `off`, which resolves whether `key` has no handlers left.
|
||||
*/
|
||||
async _addPushHandler(key, handler, onError) {
|
||||
const entry = { handler, onError };
|
||||
const entries = this.#pushHandlers.get(key) ?? new Set();
|
||||
entries.add(entry);
|
||||
this.#pushHandlers.set(key, entries);
|
||||
this.#startPushFeed();
|
||||
return async () => {
|
||||
entries.delete(entry);
|
||||
if (entries.size === 0 && this.#pushHandlers.get(key) === entries)
|
||||
this.#pushHandlers.delete(key);
|
||||
if (this.#pushHandlers.size === 0) await this.#stopPushFeed();
|
||||
return !this.#pushHandlers.has(key);
|
||||
};
|
||||
}
|
||||
|
||||
/** @internal Registers again when the worker reports a replaced subscription. */
|
||||
_resyncOnSubscriptionChange(sync) {
|
||||
const listening = this.#resync !== null;
|
||||
this.#resync = sync;
|
||||
if (listening) return;
|
||||
globalThis.navigator?.serviceWorker?.addEventListener(
|
||||
'message',
|
||||
(event) => {
|
||||
if (event.data?.type !== SUBSCRIPTION_CHANGED_MESSAGE) return;
|
||||
if (this.#pushHandlers.size > 0) void this.#resync?.();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#startPushFeed() {
|
||||
if (this.#pushFeed || this.#stopWaitingForAuth) return;
|
||||
// A visitor who hasn't signed in yet: start once they do.
|
||||
if (needsSignIn(this.puter)) {
|
||||
this.#stopWaitingForAuth = this.puter.onAuthStateChanged(() => {
|
||||
if (needsSignIn(this.puter)) return;
|
||||
this.#stopWaitingForAuth?.();
|
||||
this.#stopWaitingForAuth = null;
|
||||
this.#startPushFeed();
|
||||
});
|
||||
return;
|
||||
}
|
||||
const onError = (error) => this.#reportPush(error);
|
||||
this.#pushFeed = (async () => {
|
||||
try {
|
||||
return await this.onNotification(
|
||||
(notification) => this.#dispatchPush(notification),
|
||||
{ catchUp: false, onError },
|
||||
);
|
||||
} catch (error) {
|
||||
onError(error);
|
||||
this.#pushFeed = null;
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
async #stopPushFeed() {
|
||||
this.#stopWaitingForAuth?.();
|
||||
this.#stopWaitingForAuth = null;
|
||||
const feed = this.#pushFeed;
|
||||
this.#pushFeed = null;
|
||||
await (await feed)?.off();
|
||||
}
|
||||
|
||||
#reportPush(error) {
|
||||
const handlers = [...this.#pushHandlers.values()].flatMap((set) =>
|
||||
[...set].map((entry) => entry.onError).filter(Boolean),
|
||||
);
|
||||
if (handlers.length === 0)
|
||||
console.error('[puter.notifications] push listener failed', error);
|
||||
for (const onError of new Set(handlers)) onError(error);
|
||||
}
|
||||
|
||||
async #dispatchPush(notification) {
|
||||
const entries = this.#pushHandlers.get(notification.channel ?? '');
|
||||
if (!entries) return;
|
||||
let result;
|
||||
for (const { handler, onError } of [...entries]) {
|
||||
try {
|
||||
result = (await handler(notification)) ?? result;
|
||||
} catch (error) {
|
||||
if (onError) onError(error);
|
||||
else
|
||||
console.error(
|
||||
'[puter.notifications] push handler failed',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
await this.#showIfHidden(notification, result);
|
||||
}
|
||||
|
||||
/** A connected page that is hidden shows what the push would have. */
|
||||
async #showIfHidden(notification, result) {
|
||||
if (this.puter.env !== 'web') return;
|
||||
if (globalThis.document?.visibilityState !== 'hidden') return;
|
||||
if (globalThis.Notification?.permission !== 'granted') return;
|
||||
try {
|
||||
const registration =
|
||||
await globalThis.navigator.serviceWorker?.getRegistration();
|
||||
if (!registration) return;
|
||||
const shown = shownFrom(notification, result);
|
||||
const origin = globalThis.location.origin;
|
||||
const url = new URL(shown.url ?? '/', origin);
|
||||
const { title, url: _url, ...options } = shown;
|
||||
await registration.showNotification(title, {
|
||||
...options,
|
||||
data: {
|
||||
puter: {
|
||||
uid: notification.uid,
|
||||
target: notification.target,
|
||||
data: notification.data,
|
||||
url: url.origin === origin ? url.href : `${origin}/`,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[puter.notifications] could not show a notification',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Taps from `push-sw.js`: posted to an open page, or carried by the URL of
|
||||
* one it opened.
|
||||
*/
|
||||
#listenForWorkerClicks(firstHandler) {
|
||||
const container = globalThis.navigator?.serviceWorker;
|
||||
container?.addEventListener('message', (event) => {
|
||||
if (event.data?.type !== WORKER_CLICK_MESSAGE) return;
|
||||
const click = toClick(event.data.notification);
|
||||
if (click) this.emit('click', click);
|
||||
});
|
||||
container?.startMessages?.();
|
||||
|
||||
const url = globalThis.location
|
||||
? new URL(globalThis.location.href)
|
||||
: null;
|
||||
const uid = url?.searchParams.get(CLICK_PARAM);
|
||||
if (!uid) return;
|
||||
url.searchParams.delete(CLICK_PARAM);
|
||||
globalThis.history?.replaceState?.(
|
||||
globalThis.history.state,
|
||||
'',
|
||||
url.href,
|
||||
);
|
||||
void (async () => {
|
||||
try {
|
||||
const click = toClick(await idbGet(CLICKS_STORE, uid));
|
||||
await idbDelete(CLICKS_STORE, uid);
|
||||
if (click) firstHandler(click);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[puter.notifications] could not read a tap',
|
||||
error,
|
||||
);
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// IndexedDB shared with `push-sw.js` on the same origin; names must match it.
|
||||
export const PUSH_DB_NAME = 'puter-push';
|
||||
export const HANDLERS_STORE = 'handlers';
|
||||
export const CLICKS_STORE = 'clicks';
|
||||
|
||||
const open = (idb) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const req = idb.open(PUSH_DB_NAME, 1);
|
||||
req.onupgradeneeded = () => {
|
||||
req.result.createObjectStore(HANDLERS_STORE);
|
||||
req.result.createObjectStore(CLICKS_STORE);
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
|
||||
const run = async (idb, store, mode, action) => {
|
||||
const db = await open(idb);
|
||||
try {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const req = action(db.transaction(store, mode).objectStore(store));
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
};
|
||||
|
||||
export const idbGet = (store, key, idb = globalThis.indexedDB) =>
|
||||
run(idb, store, 'readonly', (s) => s.get(key));
|
||||
|
||||
export const idbPut = (store, key, value, idb = globalThis.indexedDB) =>
|
||||
run(idb, store, 'readwrite', (s) => s.put(value, key));
|
||||
|
||||
export const idbDelete = (store, key, idb = globalThis.indexedDB) =>
|
||||
run(idb, store, 'readwrite', (s) => s.delete(key));
|
||||
@@ -0,0 +1,214 @@
|
||||
import { PuterJSError } from '../../../lib/PuterJSError.js';
|
||||
import { request } from './api.js';
|
||||
|
||||
/** Posted by the service worker when the server's confirmation push arrives. */
|
||||
export const CHALLENGE_MESSAGE = 'puter-push-challenge';
|
||||
/** Where an app's own origin serves the worker that imports `push-sw.js`. */
|
||||
export const DEFAULT_APP_WORKER = '/puter-sw.js';
|
||||
/** The Puter GUI's own worker. */
|
||||
export const GUI_WORKER = '/sw.js';
|
||||
|
||||
const CHALLENGE_TIMEOUT_MS = 30_000;
|
||||
const WORKER_START_TIMEOUT_MS = 10_000;
|
||||
|
||||
export const webPushSupported = (g = globalThis) =>
|
||||
typeof g.navigator?.serviceWorker?.register === 'function' &&
|
||||
'PushManager' in g &&
|
||||
'Notification' in g;
|
||||
|
||||
export const fromBase64Url = (value) => {
|
||||
const padded = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const binary = atob(padded + '='.repeat((4 - (padded.length % 4)) % 4));
|
||||
return Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
||||
};
|
||||
|
||||
const sameKey = (buffer, key) => {
|
||||
if (!buffer) return false;
|
||||
const bytes = new Uint8Array(buffer);
|
||||
return bytes.length === key.length && bytes.every((b, i) => b === key[i]);
|
||||
};
|
||||
|
||||
/** The worker for this page; `GUI_WORKER` on the GUI, the app's own elsewhere. */
|
||||
export const workerFor = (env, serviceWorker) =>
|
||||
env === 'gui'
|
||||
? { url: GUI_WORKER, scope: '/' }
|
||||
: { url: serviceWorker ?? DEFAULT_APP_WORKER };
|
||||
|
||||
const untilActive = (registration) => {
|
||||
if (registration.active) return Promise.resolve(registration);
|
||||
const worker = registration.installing ?? registration.waiting;
|
||||
if (!worker) return Promise.resolve(registration);
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
worker.removeEventListener('statechange', check);
|
||||
reject(
|
||||
new PuterJSError(
|
||||
'The push service worker did not start',
|
||||
'push_worker_unavailable',
|
||||
),
|
||||
);
|
||||
}, WORKER_START_TIMEOUT_MS);
|
||||
function check() {
|
||||
if (worker.state !== 'activated' && worker.state !== 'redundant')
|
||||
return;
|
||||
clearTimeout(timer);
|
||||
worker.removeEventListener('statechange', check);
|
||||
if (worker.state === 'activated') resolve(registration);
|
||||
else
|
||||
reject(
|
||||
new PuterJSError(
|
||||
'The push service worker failed to install',
|
||||
'push_worker_unavailable',
|
||||
),
|
||||
);
|
||||
}
|
||||
worker.addEventListener('statechange', check);
|
||||
check();
|
||||
});
|
||||
};
|
||||
|
||||
const registerWorker = async (nav, worker) => {
|
||||
try {
|
||||
const registration = await nav.serviceWorker.register(worker.url, {
|
||||
...(worker.scope ? { scope: worker.scope } : {}),
|
||||
updateViaCache: 'none',
|
||||
});
|
||||
return await untilActive(registration);
|
||||
} catch (error) {
|
||||
if (error instanceof PuterJSError) throw error;
|
||||
throw new PuterJSError(
|
||||
`Could not register the push service worker at ${worker.url}`,
|
||||
'push_worker_unavailable',
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves the next challenge the worker hands this page; `cancel` stops
|
||||
* waiting.
|
||||
*/
|
||||
const nextChallenge = (nav) => {
|
||||
let cancel = () => {};
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
const onMessage = (event) => {
|
||||
const data = event.data;
|
||||
if (data?.type !== CHALLENGE_MESSAGE) return;
|
||||
if (typeof data.challenge !== 'string') return;
|
||||
cancel();
|
||||
resolve(data.challenge);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
cancel();
|
||||
reject(
|
||||
new PuterJSError(
|
||||
'The confirmation push did not arrive',
|
||||
'push_verification_timeout',
|
||||
),
|
||||
);
|
||||
}, CHALLENGE_TIMEOUT_MS);
|
||||
cancel = () => {
|
||||
clearTimeout(timer);
|
||||
nav.serviceWorker.removeEventListener('message', onMessage);
|
||||
};
|
||||
nav.serviceWorker.addEventListener('message', onMessage);
|
||||
nav.serviceWorker.startMessages?.();
|
||||
});
|
||||
return { promise, cancel: () => cancel() };
|
||||
};
|
||||
|
||||
const subscriptionBody = (subscription) => {
|
||||
const { endpoint, keys } = subscription.toJSON();
|
||||
return { endpoint, keys: { p256dh: keys?.p256dh, auth: keys?.auth } };
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscribes this browser and registers it with the server. A new subscription
|
||||
* is confirmed by a push the worker hands back; a known one is only refreshed.
|
||||
* Needs notification permission already granted.
|
||||
*
|
||||
* @returns {Promise<Record<string, unknown>>} The server's device record.
|
||||
*/
|
||||
export async function syncWebPush(
|
||||
puter,
|
||||
{ worker, label, nav = globalThis.navigator } = {},
|
||||
) {
|
||||
const { publicKey } = await request(puter, 'GET', '/push/web/key');
|
||||
if (typeof publicKey !== 'string' || publicKey === '') {
|
||||
throw new PuterJSError(
|
||||
"Push notifications aren't available on this deployment",
|
||||
'push_unavailable',
|
||||
);
|
||||
}
|
||||
const key = fromBase64Url(publicKey);
|
||||
const registration = await registerWorker(nav, worker);
|
||||
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
if (
|
||||
subscription &&
|
||||
!sameKey(subscription.options?.applicationServerKey, key)
|
||||
) {
|
||||
await subscription.unsubscribe();
|
||||
subscription = null;
|
||||
}
|
||||
subscription ??= await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: key,
|
||||
});
|
||||
|
||||
const body = {
|
||||
subscription: subscriptionBody(subscription),
|
||||
...(label ? { label } : {}),
|
||||
};
|
||||
// Listening first: the confirmation can arrive before the POST returns.
|
||||
const challenge = nextChallenge(nav);
|
||||
try {
|
||||
const result = await request(
|
||||
puter,
|
||||
'POST',
|
||||
'/push/web/subscriptions',
|
||||
body,
|
||||
);
|
||||
if (result.status === 'active') return result.device;
|
||||
const { device } = await request(
|
||||
puter,
|
||||
'POST',
|
||||
'/push/web/subscriptions/verify',
|
||||
{
|
||||
subscription: body.subscription,
|
||||
challenge: await challenge.promise,
|
||||
},
|
||||
);
|
||||
return device;
|
||||
} finally {
|
||||
challenge.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/** Ends this browser's subscription for `worker` and tells the server. */
|
||||
export async function removeWebPush(
|
||||
puter,
|
||||
{ worker, nav = globalThis.navigator } = {},
|
||||
) {
|
||||
const registration = await nav.serviceWorker.getRegistration(
|
||||
worker.scope ?? worker.url,
|
||||
);
|
||||
const subscription = await registration?.pushManager.getSubscription();
|
||||
if (!subscription) return false;
|
||||
try {
|
||||
await request(puter, 'DELETE', '/push/web/subscriptions', {
|
||||
endpoint: subscription.endpoint,
|
||||
});
|
||||
} finally {
|
||||
await subscription.unsubscribe();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** This browser's subscription for `worker`, or null. */
|
||||
export async function currentWebPush({ worker, nav = globalThis.navigator }) {
|
||||
const registration = await nav.serviceWorker.getRegistration(
|
||||
worker.scope ?? worker.url,
|
||||
);
|
||||
return (await registration?.pushManager.getSubscription()) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { PuterJSError } from '../../lib/PuterJSError.js';
|
||||
import { needsSignIn } from '../events/lib/signIn.js';
|
||||
import { HANDLERS_STORE, idbDelete, idbPut } from './lib/pushStore.js';
|
||||
import { syncWebPush, webPushSupported, workerFor } from './lib/webPush.js';
|
||||
|
||||
/** Same rule the server applies to a notification's `channel`. */
|
||||
const KEY_PATTERN = /^[a-z0-9-]{1,32}$/;
|
||||
|
||||
/** Fields a handler's result may set on the shown notification. */
|
||||
const SHOWN_FIELDS = ['title', 'body', 'icon', 'badge', 'image', 'tag', 'url'];
|
||||
|
||||
/** The handler's result, reduced to what a notification can show. */
|
||||
export const shownFrom = (notification, result) => {
|
||||
const shown = {
|
||||
title: notification.title || 'New notification',
|
||||
...(notification.text ? { body: notification.text } : {}),
|
||||
...(notification.icon ? { icon: notification.icon } : {}),
|
||||
tag: notification.group ?? notification.uid,
|
||||
...(notification.target ? { url: notification.target } : {}),
|
||||
};
|
||||
if (!result || typeof result !== 'object') return shown;
|
||||
for (const field of SHOWN_FIELDS) {
|
||||
if (typeof result[field] === 'string' && result[field] !== '')
|
||||
shown[field] = result[field];
|
||||
}
|
||||
return shown;
|
||||
};
|
||||
|
||||
const report = (onError, error) => {
|
||||
if (onError) onError(error);
|
||||
else console.error('[puter.notifications] push handler failed', error);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles this app's notifications sent with `channel: key`, live while the app
|
||||
* is connected and as a browser push while it isn't.
|
||||
*
|
||||
* The handler gets the notification and may return `{ title, body, icon, badge,
|
||||
* image, tag, url }` to change what is shown. On a website it is also saved for
|
||||
* `push-sw.js`, which runs it when a push arrives with no page open, so it must
|
||||
* be self-contained (see the docs for the limits). Once the user turned
|
||||
* notifications on with `enable()`, this keeps the browser's subscription
|
||||
* current without asking again.
|
||||
*
|
||||
* @this {import('./index.js').NotificationsModule}
|
||||
* @param {string} key
|
||||
* @param {import('./types.js').PushHandler} handler
|
||||
* @param {import('./types.js').OnPushOptions} [options]
|
||||
* @returns {Promise<import('./types.js').NotificationListener>}
|
||||
*/
|
||||
export async function onPush(key, handler, options = {}) {
|
||||
if (typeof key !== 'string' || !KEY_PATTERN.test(key)) {
|
||||
throw new PuterJSError(
|
||||
'`key` must be 1 to 32 lowercase letters, digits or -',
|
||||
'invalid_argument',
|
||||
);
|
||||
}
|
||||
if (typeof handler !== 'function') {
|
||||
throw new PuterJSError('handler must be a function', 'invalid_handler');
|
||||
}
|
||||
|
||||
const { puter } = this;
|
||||
const web = puter.env === 'web';
|
||||
if (web) await idbPut(HANDLERS_STORE, key, handler.toString());
|
||||
|
||||
const off = await this._addPushHandler(key, handler, options.onError);
|
||||
|
||||
if (
|
||||
web &&
|
||||
!needsSignIn(puter) &&
|
||||
webPushSupported() &&
|
||||
Notification.permission === 'granted'
|
||||
) {
|
||||
const worker = workerFor(puter.env, options.serviceWorker);
|
||||
const sync = async () => {
|
||||
try {
|
||||
await syncWebPush(puter, { worker });
|
||||
} catch (error) {
|
||||
report(options.onError, error);
|
||||
}
|
||||
};
|
||||
// Not awaited: a new subscription waits on a confirmation push.
|
||||
void sync();
|
||||
this._resyncOnSubscriptionChange(sync);
|
||||
}
|
||||
|
||||
return {
|
||||
off: async () => {
|
||||
const lastForKey = await off();
|
||||
if (web && lastForKey) await idbDelete(HANDLERS_STORE, key);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -90,7 +90,43 @@
|
||||
* @property {unknown} data The `data` it was sent with, or `null`.
|
||||
*/
|
||||
|
||||
/** @typedef {'web' | 'ios' | 'android'} PushPlatform */
|
||||
/**
|
||||
* What a push handler may return to change the shown notification. `url` is a
|
||||
* path or URL on the app's own origin, opened when it is tapped.
|
||||
*
|
||||
* @typedef {Object} PushShown
|
||||
* @property {string} [title]
|
||||
* @property {string} [body]
|
||||
* @property {string} [icon]
|
||||
* @property {string} [badge]
|
||||
* @property {string} [image]
|
||||
* @property {string} [tag]
|
||||
* @property {string} [url]
|
||||
*/
|
||||
|
||||
/**
|
||||
* Called for each notification sent with the handler's key. It runs in the page
|
||||
* while the app is connected and, on websites, in the service worker when a
|
||||
* push arrives: it must be self-contained there (no outside variables or
|
||||
* imports), finish within 5 seconds, and not touch the DOM.
|
||||
*
|
||||
* @typedef {(
|
||||
* notification: AppNotification,
|
||||
* ) => PushShown | void | Promise<PushShown | void>} PushHandler
|
||||
*/
|
||||
|
||||
/**
|
||||
* Options for {@link import('./onPush.js').onPush}.
|
||||
*
|
||||
* @typedef {Object} OnPushOptions
|
||||
* @property {string} [serviceWorker] Path of the app's worker that imports
|
||||
* `push-sw.js`. Default `/puter-sw.js`.
|
||||
* @property {(error: Error & { code?: string }) => void} [onError] Called when
|
||||
* the handler throws, the live listener stops, or keeping the browser's
|
||||
* subscription current fails.
|
||||
*/
|
||||
|
||||
/** @typedef {'web' | 'webpush' | 'ios' | 'android'} PushPlatform */
|
||||
|
||||
/**
|
||||
* What `registerDevice()` takes.
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mockRequest = vi.fn();
|
||||
vi.mock('./lib/api.js', () => ({
|
||||
request: (...args) => mockRequest(...args),
|
||||
}));
|
||||
const store = new Map();
|
||||
vi.mock('./lib/pushStore.js', () => ({
|
||||
HANDLERS_STORE: 'handlers',
|
||||
CLICKS_STORE: 'clicks',
|
||||
idbGet: vi.fn(async (s, k) => store.get(`${s}:${k}`)),
|
||||
idbPut: vi.fn(async (s, k, v) => void store.set(`${s}:${k}`, v)),
|
||||
idbDelete: vi.fn(async (s, k) => void store.delete(`${s}:${k}`)),
|
||||
}));
|
||||
|
||||
const { syncWebPush, removeWebPush, fromBase64Url, workerFor } =
|
||||
await import('./lib/webPush.js');
|
||||
const { NotificationsModule } = await import('./index.js');
|
||||
const { shownFrom } = await import('./onPush.js');
|
||||
|
||||
// 65 bytes, 0x04 first, as base64url.
|
||||
const KEY = Buffer.from([4, ...Array(64).fill(7)]).toString('base64url');
|
||||
|
||||
const makeSubscription = (key = fromBase64Url(KEY)) => ({
|
||||
endpoint: 'https://fcm.googleapis.com/fcm/send/abc',
|
||||
options: { applicationServerKey: key.buffer },
|
||||
toJSON: () => ({
|
||||
endpoint: 'https://fcm.googleapis.com/fcm/send/abc',
|
||||
expirationTime: null,
|
||||
keys: { p256dh: 'p', auth: 'a' },
|
||||
}),
|
||||
unsubscribe: vi.fn(async () => true),
|
||||
});
|
||||
|
||||
const makeNav = ({ existing = null } = {}) => {
|
||||
const listeners = new Set();
|
||||
const pushManager = {
|
||||
getSubscription: vi.fn(async () => existing),
|
||||
subscribe: vi.fn(async () => makeSubscription()),
|
||||
};
|
||||
const registration = { active: {}, pushManager };
|
||||
return {
|
||||
pushManager,
|
||||
registration,
|
||||
post: (data) => {
|
||||
for (const fn of [...listeners]) fn({ data });
|
||||
},
|
||||
serviceWorker: {
|
||||
register: vi.fn(async () => registration),
|
||||
getRegistration: vi.fn(async () => registration),
|
||||
addEventListener: (type, fn) => listeners.add(fn),
|
||||
removeEventListener: (type, fn) => listeners.delete(fn),
|
||||
startMessages: vi.fn(),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const puter = { env: 'web', authToken: 't', APIOrigin: 'https://api.test' };
|
||||
const worker = workerFor('web');
|
||||
|
||||
beforeEach(() => {
|
||||
mockRequest.mockReset();
|
||||
store.clear();
|
||||
});
|
||||
|
||||
describe('syncWebPush', () => {
|
||||
it('refreshes a subscription the server already has', async () => {
|
||||
const nav = makeNav({ existing: makeSubscription() });
|
||||
mockRequest.mockImplementation(async (_p, method, route) => {
|
||||
if (route === '/push/web/key') return { publicKey: KEY };
|
||||
return { status: 'active', device: { uid: 'd1' } };
|
||||
});
|
||||
await expect(syncWebPush(puter, { worker, nav })).resolves.toEqual({
|
||||
uid: 'd1',
|
||||
});
|
||||
expect(nav.serviceWorker.register).toHaveBeenCalledWith(
|
||||
'/puter-sw.js',
|
||||
{
|
||||
updateViaCache: 'none',
|
||||
},
|
||||
);
|
||||
expect(nav.pushManager.subscribe).not.toHaveBeenCalled();
|
||||
expect(mockRequest).toHaveBeenCalledWith(
|
||||
puter,
|
||||
'POST',
|
||||
'/push/web/subscriptions',
|
||||
{
|
||||
subscription: {
|
||||
endpoint: 'https://fcm.googleapis.com/fcm/send/abc',
|
||||
keys: { p256dh: 'p', auth: 'a' },
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('confirms a new subscription with the challenge the worker hands back', async () => {
|
||||
const nav = makeNav();
|
||||
mockRequest.mockImplementation(async (_p, method, route, body) => {
|
||||
if (route === '/push/web/key') return { publicKey: KEY };
|
||||
if (route === '/push/web/subscriptions') {
|
||||
queueMicrotask(() =>
|
||||
nav.post({
|
||||
type: 'puter-push-challenge',
|
||||
challenge: 'c.sig',
|
||||
}),
|
||||
);
|
||||
return { status: 'pending' };
|
||||
}
|
||||
return { device: { uid: 'd2', challenge: body.challenge } };
|
||||
});
|
||||
await expect(syncWebPush(puter, { worker, nav })).resolves.toEqual({
|
||||
uid: 'd2',
|
||||
challenge: 'c.sig',
|
||||
});
|
||||
expect(nav.pushManager.subscribe).toHaveBeenCalledWith({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: fromBase64Url(KEY),
|
||||
});
|
||||
});
|
||||
|
||||
it('replaces a subscription made with another key', async () => {
|
||||
const stale = makeSubscription(new Uint8Array([4, 1, 2]));
|
||||
const nav = makeNav({ existing: stale });
|
||||
mockRequest.mockImplementation(async (_p, method, route) =>
|
||||
route === '/push/web/key'
|
||||
? { publicKey: KEY }
|
||||
: { status: 'active', device: { uid: 'd3' } },
|
||||
);
|
||||
await syncWebPush(puter, { worker, nav });
|
||||
expect(stale.unsubscribe).toHaveBeenCalled();
|
||||
expect(nav.pushManager.subscribe).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails with push_unavailable when the server has no key', async () => {
|
||||
const nav = makeNav();
|
||||
mockRequest.mockResolvedValue({});
|
||||
await expect(syncWebPush(puter, { worker, nav })).rejects.toMatchObject(
|
||||
{
|
||||
code: 'push_unavailable',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeWebPush', () => {
|
||||
it('tells the server, then ends the subscription', async () => {
|
||||
const subscription = makeSubscription();
|
||||
const nav = makeNav({ existing: subscription });
|
||||
mockRequest.mockResolvedValue({ removed: true });
|
||||
await expect(removeWebPush(puter, { worker, nav })).resolves.toBe(true);
|
||||
expect(mockRequest).toHaveBeenCalledWith(
|
||||
puter,
|
||||
'DELETE',
|
||||
'/push/web/subscriptions',
|
||||
{ endpoint: subscription.endpoint },
|
||||
);
|
||||
expect(subscription.unsubscribe).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('workerFor', () => {
|
||||
it('uses the GUI worker on the GUI and the app`s own elsewhere', () => {
|
||||
expect(workerFor('gui')).toEqual({ url: '/sw.js', scope: '/' });
|
||||
expect(workerFor('web')).toEqual({ url: '/puter-sw.js' });
|
||||
expect(workerFor('web', '/js/sw.js')).toEqual({ url: '/js/sw.js' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('shownFrom', () => {
|
||||
const notification = {
|
||||
uid: 'n1',
|
||||
title: 'Hi',
|
||||
text: 'body',
|
||||
icon: null,
|
||||
target: '/chat/1',
|
||||
group: null,
|
||||
};
|
||||
|
||||
it('defaults to the notification`s own fields', () => {
|
||||
expect(shownFrom(notification, undefined)).toEqual({
|
||||
title: 'Hi',
|
||||
body: 'body',
|
||||
tag: 'n1',
|
||||
url: '/chat/1',
|
||||
});
|
||||
});
|
||||
|
||||
it('takes only the shown fields a handler returns as strings', () => {
|
||||
expect(
|
||||
shownFrom(notification, { title: 'New', body: 7, onclick: 'x' }),
|
||||
).toEqual({ title: 'New', body: 'body', tag: 'n1', url: '/chat/1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('onPush', () => {
|
||||
let feedHandler;
|
||||
const makeModule = () => {
|
||||
const module = new NotificationsModule({
|
||||
...puter,
|
||||
onAuthStateChanged: () => () => {},
|
||||
});
|
||||
module.onNotification = vi.fn(async (handler) => {
|
||||
feedHandler = handler;
|
||||
return { off: vi.fn(async () => {}) };
|
||||
});
|
||||
return module;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
feedHandler = undefined;
|
||||
});
|
||||
|
||||
it('rejects a key the server would not accept as a channel', async () => {
|
||||
const module = makeModule();
|
||||
await expect(module.onPush('Chat!', () => {})).rejects.toMatchObject({
|
||||
code: 'invalid_argument',
|
||||
});
|
||||
});
|
||||
|
||||
it('saves the handler for the worker and runs it for its channel only', async () => {
|
||||
const module = makeModule();
|
||||
const chat = vi.fn();
|
||||
const listener = await module.onPush('chat', chat);
|
||||
expect(store.get('handlers:chat')).toBe(chat.toString());
|
||||
expect(module.onNotification).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ catchUp: false }),
|
||||
);
|
||||
|
||||
await feedHandler({ uid: 'n1', channel: 'other' });
|
||||
await feedHandler({ uid: 'n2', channel: 'chat' });
|
||||
expect(chat).toHaveBeenCalledTimes(1);
|
||||
expect(chat).toHaveBeenCalledWith({ uid: 'n2', channel: 'chat' });
|
||||
|
||||
await listener.off();
|
||||
expect(store.has('handlers:chat')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the saved handler while another listener for the key remains', async () => {
|
||||
const module = makeModule();
|
||||
const first = await module.onPush('chat', () => {});
|
||||
await module.onPush('chat', () => {});
|
||||
await first.off();
|
||||
expect(store.has('handlers:chat')).toBe(true);
|
||||
});
|
||||
|
||||
it('starts one feed for every key', async () => {
|
||||
const module = makeModule();
|
||||
await module.onPush('chat', () => {});
|
||||
await module.onPush('mail', () => {});
|
||||
expect(module.onNotification).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
// Puter push notifications on an app's own origin, served at
|
||||
// https://js.puter.com/v2/push-sw.js. The app serves a worker (default
|
||||
// `/puter-sw.js`) that contains:
|
||||
//
|
||||
// importScripts('https://js.puter.com/v2/push-sw.js');
|
||||
//
|
||||
// It shows each Puter push, running the handler the page saved with
|
||||
// `puter.notifications.onPush(key, handler)`, and routes taps back to the app.
|
||||
// A handler that needs imports can be registered in the worker itself with
|
||||
// `puterPush.onPush(key, handler)`, which wins over a saved one.
|
||||
//
|
||||
// No install, activate or fetch handlers, so importing it into an existing
|
||||
// worker leaves that worker's lifecycle alone. Pushes without a `puter`
|
||||
// message are left to the app's own listeners.
|
||||
(() => {
|
||||
if (self.puterPush) return;
|
||||
|
||||
// Shared with puter.js on the same origin (modules/notifications/lib/pushStore.js).
|
||||
const DB_NAME = 'puter-push';
|
||||
const HANDLERS = 'handlers';
|
||||
const CLICKS = 'clicks';
|
||||
const HANDLER_TIMEOUT_MS = 5000;
|
||||
const CLICK_KEEP_MS = 5 * 60 * 1000;
|
||||
const DEFAULT_TITLE = 'New notification';
|
||||
const CHALLENGE_MESSAGE = 'puter-push-challenge';
|
||||
const CLICK_MESSAGE = 'puter-notification-click';
|
||||
const CHANGED_MESSAGE = 'puter-push-subscription-changed';
|
||||
const CLICK_PARAM = 'puter.notification';
|
||||
const SHOWN_FIELDS = ['title', 'body', 'icon', 'badge', 'image', 'tag', 'url'];
|
||||
|
||||
const registered = new Map();
|
||||
|
||||
const openDB = () => new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, 1);
|
||||
req.onupgradeneeded = () => {
|
||||
req.result.createObjectStore(HANDLERS);
|
||||
req.result.createObjectStore(CLICKS);
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
|
||||
const idb = async (store, mode, action) => {
|
||||
const db = await openDB();
|
||||
try {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const req = action(db.transaction(store, mode).objectStore(store));
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
};
|
||||
|
||||
const str = (value) => (typeof value === 'string' && value !== '' ? value : null);
|
||||
|
||||
function readMessage (data) {
|
||||
let payload;
|
||||
try {
|
||||
payload = data ? data.json() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
let message = payload && typeof payload === 'object'
|
||||
? (payload.puter ?? payload.data?.puter)
|
||||
: null;
|
||||
if ( typeof message === 'string' ) {
|
||||
try {
|
||||
message = JSON.parse(message);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return message && typeof message === 'object' ? message : null;
|
||||
}
|
||||
|
||||
// The shape `onPush` handlers get in the page.
|
||||
const toNotification = (message) => ({
|
||||
uid: str(message.uid) ?? '',
|
||||
title: str(message.title) ?? '',
|
||||
text: str(message.body),
|
||||
icon: str(message.icon),
|
||||
target: str(message.target?.path),
|
||||
data: message.data ?? null,
|
||||
group: str(message.group),
|
||||
channel: str(message.channel),
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
app: message.app && typeof message.app === 'object' ? message.app : null,
|
||||
});
|
||||
|
||||
async function handlerFor (channel) {
|
||||
if ( ! channel ) return null;
|
||||
if ( registered.has(channel) ) return registered.get(channel);
|
||||
const source = await idb(HANDLERS, 'readonly', (s) => s.get(channel));
|
||||
if ( typeof source !== 'string' ) return null;
|
||||
const fn = new Function(`"use strict"; return (${source});`)();
|
||||
return typeof fn === 'function' ? fn : null;
|
||||
}
|
||||
|
||||
const withTimeout = (promise) => Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('push handler timed out')), HANDLER_TIMEOUT_MS)),
|
||||
]);
|
||||
|
||||
async function render (notification) {
|
||||
const shown = {
|
||||
title: notification.title || DEFAULT_TITLE,
|
||||
...(notification.text ? { body: notification.text } : {}),
|
||||
...(notification.icon ? { icon: notification.icon } : {}),
|
||||
tag: notification.group ?? notification.uid,
|
||||
...(notification.target ? { url: notification.target } : {}),
|
||||
};
|
||||
try {
|
||||
const handler = await handlerFor(notification.channel);
|
||||
const result = handler ? await withTimeout(Promise.resolve().then(() => handler(notification))) : null;
|
||||
if ( result && typeof result === 'object' ) {
|
||||
for ( const field of SHOWN_FIELDS ) {
|
||||
if ( str(result[field]) ) shown[field] = result[field];
|
||||
}
|
||||
}
|
||||
} catch ( err ) {
|
||||
console.warn('[puter] push handler failed', err);
|
||||
}
|
||||
return shown;
|
||||
}
|
||||
|
||||
// Taps only ever open this origin.
|
||||
function sameOriginUrl (url) {
|
||||
try {
|
||||
const parsed = new URL(url || '/', self.location.origin);
|
||||
return parsed.origin === self.location.origin ? parsed.href : `${self.location.origin}/`;
|
||||
} catch {
|
||||
return `${self.location.origin}/`;
|
||||
}
|
||||
}
|
||||
|
||||
async function windows () {
|
||||
const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||
return all.filter((c) => {
|
||||
try {
|
||||
return new URL(c.url).origin === self.location.origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function showPush (message) {
|
||||
if ( message.type === 'challenge' ) {
|
||||
await self.registration.showNotification(str(message.title) ?? DEFAULT_TITLE, {
|
||||
...(str(message.body) ? { body: message.body } : {}),
|
||||
tag: str(message.group) ?? 'puter-push-challenge',
|
||||
});
|
||||
if ( str(message.challenge) ) {
|
||||
for ( const win of await windows() ) win.postMessage({ type: CHALLENGE_MESSAGE, challenge: message.challenge });
|
||||
}
|
||||
return;
|
||||
}
|
||||
const notification = toNotification(message);
|
||||
const { title, url, ...options } = await render(notification);
|
||||
const data = {
|
||||
puter: {
|
||||
uid: notification.uid,
|
||||
target: notification.target,
|
||||
data: notification.data,
|
||||
url: sameOriginUrl(url),
|
||||
},
|
||||
};
|
||||
try {
|
||||
await self.registration.showNotification(title, { ...options, data });
|
||||
} catch {
|
||||
// Browsers count a push with nothing shown against the site.
|
||||
await self.registration.showNotification(DEFAULT_TITLE, { data });
|
||||
}
|
||||
}
|
||||
|
||||
async function forgetOldClicks () {
|
||||
const cutoff = Date.now() - CLICK_KEEP_MS;
|
||||
await idb(CLICKS, 'readwrite', (s) => {
|
||||
const req = s.openCursor();
|
||||
req.addEventListener('success', () => {
|
||||
const cursor = req.result;
|
||||
if ( ! cursor ) return;
|
||||
if ( ! (cursor.value?.at > cutoff) ) cursor.delete();
|
||||
cursor.continue();
|
||||
});
|
||||
return req;
|
||||
});
|
||||
}
|
||||
|
||||
async function routeClick (puter) {
|
||||
const click = { uid: puter.uid, target: puter.target ?? null, data: puter.data ?? null };
|
||||
const url = sameOriginUrl(puter.url);
|
||||
const open = await windows();
|
||||
const win = open.find((c) => c.url === url) ?? open.find((c) => c.focused) ?? open[0];
|
||||
if ( win ) {
|
||||
win.postMessage({ type: CLICK_MESSAGE, notification: click });
|
||||
try {
|
||||
await win.focus();
|
||||
} catch {
|
||||
// focus is refused once the click's window has passed
|
||||
}
|
||||
return;
|
||||
}
|
||||
if ( click.uid ) {
|
||||
try {
|
||||
await forgetOldClicks();
|
||||
await idb(CLICKS, 'readwrite', (s) => s.put({ ...click, at: Date.now() }, click.uid));
|
||||
} catch ( err ) {
|
||||
console.warn('[puter] could not keep a tap', err);
|
||||
}
|
||||
}
|
||||
const opened = new URL(url);
|
||||
if ( click.uid ) opened.searchParams.set(CLICK_PARAM, click.uid);
|
||||
await self.clients.openWindow(opened.href);
|
||||
}
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
const message = readMessage(event.data);
|
||||
if ( ! message ) return;
|
||||
event.waitUntil(showPush(message));
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
const puter = event.notification.data?.puter;
|
||||
if ( ! puter || typeof puter !== 'object' ) return;
|
||||
event.notification.close();
|
||||
event.waitUntil(routeClick(puter));
|
||||
});
|
||||
|
||||
self.addEventListener('pushsubscriptionchange', (event) => {
|
||||
// The worker has no Puter session; an open page registers the new one.
|
||||
event.waitUntil((async () => {
|
||||
for ( const win of await windows() ) win.postMessage({ type: CHANGED_MESSAGE });
|
||||
})());
|
||||
});
|
||||
|
||||
self.puterPush = {
|
||||
/** Handles pushes for `key` in the worker, over a handler the page saved. */
|
||||
onPush (key, handler) {
|
||||
if ( typeof key !== 'string' || typeof handler !== 'function' ) {
|
||||
throw new TypeError('onPush(key, handler) takes a string and a function');
|
||||
}
|
||||
registered.set(key, handler);
|
||||
},
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,223 @@
|
||||
// @vitest-environment node
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const SOURCE = readFileSync(
|
||||
fileURLToPath(new URL('./push-sw.js', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
const ORIGIN = 'https://app.example';
|
||||
|
||||
/** Enough of IndexedDB for get/put and a cursor walk. */
|
||||
const fakeIndexedDB = (stores) => {
|
||||
const request = (run) => {
|
||||
const req = { listeners: [] };
|
||||
req.addEventListener = (_t, fn) => req.listeners.push(fn);
|
||||
queueMicrotask(() => {
|
||||
try {
|
||||
req.result = run(req);
|
||||
req.onsuccess?.();
|
||||
for (const fn of req.listeners) fn();
|
||||
} catch (error) {
|
||||
req.error = error;
|
||||
req.onerror?.();
|
||||
}
|
||||
});
|
||||
return req;
|
||||
};
|
||||
const objectStore = (name) => {
|
||||
const map = (stores[name] ??= new Map());
|
||||
return {
|
||||
get: (key) => request(() => map.get(key)),
|
||||
put: (value, key) => request(() => void map.set(key, value)),
|
||||
openCursor: () => request(() => null),
|
||||
};
|
||||
};
|
||||
return {
|
||||
open: () => {
|
||||
const req = request(() => ({
|
||||
transaction: (name) => ({ objectStore: () => objectStore(name) }),
|
||||
close: () => {},
|
||||
createObjectStore: () => {},
|
||||
}));
|
||||
return req;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const windowClient = (url, over = {}) => ({
|
||||
url,
|
||||
focused: false,
|
||||
focus: vi.fn(async () => {}),
|
||||
postMessage: vi.fn(),
|
||||
...over,
|
||||
});
|
||||
|
||||
const loadWorker = ({ handlers = {}, windows = [] } = {}) => {
|
||||
const listeners = {};
|
||||
const shown = [];
|
||||
const stores = { handlers: new Map(Object.entries(handlers)) };
|
||||
const scope = {
|
||||
location: { origin: ORIGIN },
|
||||
addEventListener: (type, fn) => {
|
||||
listeners[type] = fn;
|
||||
},
|
||||
registration: {
|
||||
showNotification: vi.fn(async (title, options) => {
|
||||
shown.push({ title, options });
|
||||
}),
|
||||
},
|
||||
clients: {
|
||||
matchAll: vi.fn(async () => windows),
|
||||
openWindow: vi.fn(async () => null),
|
||||
},
|
||||
};
|
||||
scope.self = scope;
|
||||
const context = vm.createContext({
|
||||
...scope,
|
||||
URL,
|
||||
JSON,
|
||||
Math,
|
||||
Map,
|
||||
Promise,
|
||||
Error,
|
||||
TypeError,
|
||||
Date,
|
||||
console,
|
||||
queueMicrotask,
|
||||
setTimeout,
|
||||
Function,
|
||||
indexedDB: fakeIndexedDB(stores),
|
||||
});
|
||||
context.self = context;
|
||||
vm.runInContext(SOURCE, context);
|
||||
const fire = async (type, event) => {
|
||||
let pending = Promise.resolve();
|
||||
listeners[type]?.({ ...event, waitUntil: (p) => (pending = p) });
|
||||
await pending;
|
||||
};
|
||||
return { context, scope, listeners, shown, stores, fire };
|
||||
};
|
||||
|
||||
const pushData = (payload) => ({ json: () => payload });
|
||||
|
||||
describe('push-sw push', () => {
|
||||
it('leaves a push without a Puter message to the app', async () => {
|
||||
const worker = loadWorker();
|
||||
await worker.fire('push', { data: pushData({ hello: 1 }) });
|
||||
expect(worker.shown).toEqual([]);
|
||||
});
|
||||
|
||||
it('shows the message as sent when no handler is saved', async () => {
|
||||
const worker = loadWorker();
|
||||
await worker.fire('push', {
|
||||
data: pushData({
|
||||
puter: { uid: 'n1', title: 'Hi', body: 'there', target: { path: '/c/1' } },
|
||||
}),
|
||||
});
|
||||
expect(worker.shown).toEqual([
|
||||
{
|
||||
title: 'Hi',
|
||||
options: {
|
||||
body: 'there',
|
||||
tag: 'n1',
|
||||
data: {
|
||||
puter: {
|
||||
uid: 'n1',
|
||||
target: '/c/1',
|
||||
data: null,
|
||||
url: `${ORIGIN}/c/1`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('runs the handler the page saved for the channel', async () => {
|
||||
const worker = loadWorker({
|
||||
handlers: {
|
||||
chat: '(n) => ({ title: n.app.name + ": " + n.title, url: "/chat" })',
|
||||
},
|
||||
});
|
||||
await worker.fire('push', {
|
||||
data: pushData({
|
||||
puter: { uid: 'n1', title: 'Hi', channel: 'chat', app: { uid: 'a', name: 'Chat' } },
|
||||
}),
|
||||
});
|
||||
expect(worker.shown[0].title).toBe('Chat: Hi');
|
||||
expect(worker.shown[0].options.data.puter.url).toBe(`${ORIGIN}/chat`);
|
||||
});
|
||||
|
||||
it('prefers a handler registered in the worker', async () => {
|
||||
const worker = loadWorker({ handlers: { chat: '() => ({ title: "saved" })' } });
|
||||
worker.context.puterPush.onPush('chat', () => ({ title: 'worker' }));
|
||||
await worker.fire('push', {
|
||||
data: pushData({ puter: { uid: 'n1', title: 'Hi', channel: 'chat' } }),
|
||||
});
|
||||
expect(worker.shown[0].title).toBe('worker');
|
||||
});
|
||||
|
||||
it('still shows the defaults when the handler throws', async () => {
|
||||
const worker = loadWorker({ handlers: { chat: '() => { throw new Error("x") }' } });
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
await worker.fire('push', {
|
||||
data: pushData({ puter: { uid: 'n1', title: 'Hi', channel: 'chat' } }),
|
||||
});
|
||||
expect(worker.shown[0].title).toBe('Hi');
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('never points a tap at another origin', async () => {
|
||||
const worker = loadWorker({ handlers: { chat: '() => ({ url: "https://evil.example/x" })' } });
|
||||
await worker.fire('push', {
|
||||
data: pushData({ puter: { uid: 'n1', title: 'Hi', channel: 'chat' } }),
|
||||
});
|
||||
expect(worker.shown[0].options.data.puter.url).toBe(`${ORIGIN}/`);
|
||||
});
|
||||
|
||||
it('shows a challenge and hands it to this origin`s windows', async () => {
|
||||
const mine = windowClient(`${ORIGIN}/`);
|
||||
const other = windowClient('https://elsewhere.example/');
|
||||
const worker = loadWorker({ windows: [mine, other] });
|
||||
await worker.fire('push', {
|
||||
data: pushData({
|
||||
puter: { type: 'challenge', challenge: 'c.sig', title: 'Notifications are on', group: 'puter-push-challenge' },
|
||||
}),
|
||||
});
|
||||
expect(worker.shown.map((n) => n.title)).toEqual(['Notifications are on']);
|
||||
expect(mine.postMessage).toHaveBeenCalledWith({ type: 'puter-push-challenge', challenge: 'c.sig' });
|
||||
expect(other.postMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('push-sw notificationclick', () => {
|
||||
const tap = (puter) => ({ notification: { data: { puter }, close: vi.fn() } });
|
||||
|
||||
it('hands the tap to an open window of the app', async () => {
|
||||
const open = windowClient(`${ORIGIN}/chat`);
|
||||
const worker = loadWorker({ windows: [open] });
|
||||
await worker.fire('notificationclick', tap({ uid: 'n1', target: '/chat', data: { id: 1 }, url: `${ORIGIN}/chat` }));
|
||||
expect(open.postMessage).toHaveBeenCalledWith({
|
||||
type: 'puter-notification-click',
|
||||
notification: { uid: 'n1', target: '/chat', data: { id: 1 } },
|
||||
});
|
||||
expect(open.focus).toHaveBeenCalled();
|
||||
expect(worker.scope.clients.openWindow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens the app with the tap in the URL and keeps it for the page', async () => {
|
||||
const worker = loadWorker();
|
||||
await worker.fire('notificationclick', tap({ uid: 'n1', target: '/chat', data: null, url: `${ORIGIN}/chat` }));
|
||||
expect(worker.scope.clients.openWindow).toHaveBeenCalledWith(`${ORIGIN}/chat?puter.notification=n1`);
|
||||
expect(worker.stores.clicks.get('n1')).toMatchObject({ uid: 'n1', target: '/chat' });
|
||||
});
|
||||
|
||||
it('ignores the app`s own notifications', async () => {
|
||||
const worker = loadWorker();
|
||||
await worker.fire('notificationclick', { notification: { data: { mine: true }, close: vi.fn() } });
|
||||
expect(worker.scope.clients.openWindow).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import webpack from 'webpack';
|
||||
|
||||
@@ -10,6 +11,23 @@ import webpack from 'webpack';
|
||||
import { fileURLToPath } from 'url';
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// `push-sw.js` is a standalone classic script that apps import into their own
|
||||
// service worker; it ships next to the bundle as-is.
|
||||
const PUSH_WORKER = path.resolve(__dirname, 'src/push-sw.js');
|
||||
class EmitPushWorker {
|
||||
apply(compiler) {
|
||||
compiler.hooks.thisCompilation.tap('EmitPushWorker', (compilation) => {
|
||||
compilation.fileDependencies.add(PUSH_WORKER);
|
||||
compilation.hooks.processAssets.tap(
|
||||
{ name: 'EmitPushWorker', stage: webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL },
|
||||
() => {
|
||||
compilation.emitAsset('push-sw.js', new webpack.sources.RawSource(readFileSync(PUSH_WORKER, 'utf8')));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default (env = {}) => ({
|
||||
entry: './src/index.js',
|
||||
output: {
|
||||
@@ -58,6 +76,7 @@ export default (env = {}) => ({
|
||||
},
|
||||
} : {}),
|
||||
plugins: [
|
||||
new EmitPushWorker(),
|
||||
new webpack.DefinePlugin({
|
||||
'globalThis.PUTER_ORIGIN_ENV': JSON.stringify(process.env.PUTER_ORIGIN || 'https://puter.com'),
|
||||
'globalThis.PUTER_API_ORIGIN_ENV': JSON.stringify(process.env.PUTER_API_ORIGIN || 'https://api.puter.com'),
|
||||
|
||||
Reference in new issue
Block a user