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:
Daniel Salazar committed 2026-10-28 01:05:36 -07:00
1 parent 3b4945f018
commit 40253ae12b
26 files changed
+1733 -14

No files matched your search

+13 -1
View File
@@ -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;
+4 -5
View File
@@ -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.
+21
View File
@@ -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.
+29
View File
@@ -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.
+85
View File
@@ -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>
```
+24
View File
@@ -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>",
+5
View File
@@ -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 });
+55
View File
@@ -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 };
},
};
};
+64
View File
@@ -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
View File
@@ -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) => {
+21
View File
@@ -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();
+3
View File
@@ -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);
});
});
+248
View File
@@ -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);
},
};
})();
+223
View File
@@ -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();
});
});
+19
View File
@@ -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'),