A push is skipped only when a page that got the notification live was
visible and focused. Hidden, unfocused or unanswering pages no longer hold
it back, and pages no longer show browser notifications themselves, so the
push reaches every subscribed browser.
- Receipts carry the page's visibility and focus as of arrival; the settle
gains `handled`. `showsOwn` is gone.
- Pages send `notif.handled { uid }` on the socket a notification arrived on
when they got it while visible and focused. Core relays it as the
node-local `notif.handled` event (60 a minute per user) for extensions.
- puter.js drops the page-shown notification path; push-sw.js drops its
page-show message. Handlers still run live in the page.
- The desktop registers /sw.js only when push is turned on, updates it when
subscribed, and removes one left without a subscription. Its hidden-tab
banners are gone.
- Docs describe the focus rule.
7.7 KiB
Browser push client
Puter's desktop shows pushes through /sw.js. Core shows Settings → Notifications only when the pushEnabled gui param is true; core never sets it. The worker is registered only when push is turned on in the browser, and a load removes one left without a subscription. A backend extension that delivers pushes sets it with services.homepage.setGuiParam('pushEnabled', true) and can load its own code into the worker with services.homepage.registerServiceWorkerScript(path).
Core subscribes the browser to a push service only when the server also publishes pushWebPushKey (see Built-in Web Push client), and it has no /push/* routes of its own. The device, app, preference and subscription endpoints below come from that extension. On a deployment without one, such as most self-hosted servers, they answer core's plain 404 (code: 'not_found'), which puter.notifications reports as push_unavailable and the Notifications tab shows as unavailable. A device the extension can't find is device_not_found.
Push payload
sw.js reads the push's JSON body. The message is its puter field, or data.puter for services that only carry string data, as an object or a JSON string:
{ uid?: string; title?: string; body?: string; icon?: string; group?: string;
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.
When the browser replaces the subscription (pushsubscriptionchange), sw.js posts { type: 'puter-push-subscription-changed' } to every open window on Puter's origin. The worker has no token or session, so the page's push client registers the new subscription.
Receipts
Before a notification is pushed, the server asks the open clients whether they received it live. It sends notif.receipt with { uid } on the user's sockets for the app that sent it, or on the desktop's sockets for Puter's own notifications. The SDK remembers the uids its page received through onNotification(), onPush() or the desktop's feed, and answers once its page has that notification, or with { received: false } when it never gets it. The server waits up to 5 seconds (notificationAckTimeoutMs).
An answer carries visibilityState and focused as they were when the page received the notification. Pages never show a browser notification themselves; pushes do.
- An answer from a page that was visible and focused stops the push (
handledonnotif.delivery.settled). An older client that doesn't sendfocusedcounts while visible. - Otherwise the notification is pushed, to every browser the user subscribed. A website's pushes go only to the browsers that subscribed through its own worker, never to Puter's
/sw.js. An app inside the desktop pushes through/sw.js, which shows the app's name and icon. - A client that can't answer, such as an older puter.js, doesn't hold the push back.
The ask reaches only the sockets in the asking region. So that other regions hear about a page too, a page that receives a notification live while visible and focused also sends notif.handled with { uid } on the socket it came on. Core relays it as the node-local notif.handled event with the socket's user and app, at most 60 a minute per user; an extension that delivers pushes decides what to do with it.
Registering
The registry is in src/gui/src/helpers/pushClient.js. A service script registers its client like this:
service_script(async (api) => {
api.use()['push.registerPushClient']?.(client);
});
use(id) throws when nothing is registered under id, so the form above also runs on a Puter version without the registry and does nothing there. Any script that runs after the GUI bundle can call use()['push.registerPushClient']?.(client) the same way. Registering again replaces the client. Registering dispatches push-client-registered on window, and an open Notifications tab redraws.
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) unless an extension registered one first. It uses puter.notifications.enable() / disable(), the same standard Web Push flow apps use on their own origins:
GET /push/web/keyfor the key, thenpushManager.subscribe()on the/sw.jsregistration.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 }.- The worker shows that push and posts
{ type: 'puter-push-challenge', challenge }to the origin's windows; the page sends it toPOST /push/web/subscriptions/verify. The device is registered once the page hands the confirmation back. 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
interface PushClient {
enable(): Promise<{ deviceUid: string }>;
disable(): Promise<void>;
status(): Promise<{
permission: 'default' | 'granted' | 'denied';
registered: boolean;
deviceUid?: string;
}>;
}
enable()may ask for notification permission, so core calls it from a click: Turn on in the Notifications tab, Allow on a permission prompt that includesnotifications:pushwhile this browser is off, and the "Turn on notifications" button of the/action/enable-pushpopup and the matching desktop dialog (UIEnablePushDialog). Core also calls it without a click, fromhelpers/pushSync.js, whenNotification.permissionis alreadygranted,status()reports the browser as not registered, the user has not turned push off in this browser, and an app holdsnotifications:push; the browser does not prompt then. The built-in client runsputer.notifications.enable(), which subscribes with the/sw.jsregistration and registers the browser through the Web Push flow above (platform: 'webpush'). It then resolves theuidof this browser'swebpushdevice fromlistDevices(). It rejects when permission is refused or the browser wasn't registered.disable()runsputer.notifications.disable(), which sendsDELETE /push/web/subscriptions { endpoint }and ends the browser's subscription. The tab calls it for Turn off, and for Remove on the device marked as this browser. Remove on any other device isDELETE /push/devices/:uid.status()reports the state without prompting.permissionisNotification.permission.registeredis true when this browser has a device registered for the signed-in user, anddeviceUidis that device'suid.
After enable() resolves, the tab calls POST /push/devices/:uid/test and asks the user whether a notification showed up.
The tab also reads GET /push/apps and GET/POST /push/preferences.
Core keeps its own record of a browser the user turned off, puter.push.browser-off:<user uuid> in localStorage, written by Turn off and by Remove on this browser's device, and cleared when enable() resolves. The silent registration above skips a browser with that record, so a device removed here stays removed while one removed from another device, or a new sign-in in a browser that already allows notifications, registers again.