fix(puter-js): push taps open the right page, once (#4472)

* fix(puter-js): a tap without a URL opens the site the push came from

A page that calls onPush() or enable() saves where taps resolve for its
worker: the worker's folder when the page is inside it, else the site's
root. push-sw.js and the notifications a page shows itself read relative
URLs from it, and fall back to the worker's scope when nothing is saved.

* fix(puter-js): push taps go to top-level windows only

push-sw.js routes taps, and holds back a page's notification for a focused
window, among top-level windows only, as the GUI's worker does. Challenges
and subscription changes still reach every window of the origin.

* fix(puter-js): deliver a push tap to the page once

push-sw.js keeps a tap before posting it to an open page. The page runs
its click handler only after taking that copy out of IndexedDB in one
readwrite transaction, and acknowledges only then. When no
acknowledgement comes in time, the worker takes the copy itself and opens
the tap's URL only if the page hadn't. The wait starts at the post and
ends at most 800 ms after the click, so openWindow still runs in time.
Pages and workers without this keep today's behaviour.

* fix(puter-js): clear stored push taps that nobody reads

push-sw.js removes taps kept over five minutes on every push and tap, and
a page sweeps them once when onPush() syncs. A page opened for a tap
ignores a kept tap older than that, and removes it when no click handler
is added within 10 seconds of loading.

* fix(puter-js): a late page already at the tap's URL still gets the tap

When no acknowledgement comes in time and the window is already at the
tap's URL, push-sw.js leaves the copy it kept for that page instead of
taking it back, so the page's click handler still runs when it answers
late. A copy nobody takes is removed with the other old taps.

* fix(puter-js): a push tap runs at most once when IndexedDB fails

A page that can't take a kept tap out of IndexedDB leaves it to the
worker instead of running it, and a worker whose claim fails leaves it
to the page instead of opening the tap's URL.

* fix(puter-js): take a tap back and keep it for the next page in one step

When no acknowledgement comes in time, push-sw.js takes its copy back and
keeps it, without the id, for the page it opens in one readwrite
transaction, leaving more of Firefox's window-opening allowance for
openWindow.

* test(puter-js): shorten the fake IndexedDB comment
This commit is contained in:
Juan Fernando Castro authored and GitHub committed 2026-11-03 10:06:16 -05:00
1 parent 8f3d4cc40f
commit 9bd7ba7079
10 files changed
+673 -60

No files matched your search

+1 -1
View File
@@ -26,7 +26,7 @@ puter.notifications.notify(notification)
- `data` (any): A JSON value of up to 1 KB, handed back on click. Push services can read it, so put ids in it, not personal content.
- `group` (String): A later notification with the same `group` replaces this one, as [Groups](#groups) describes. Up to 128 letters, digits and `. _ : @ -`.
- `channel` (String): What kind of notification it is, such as `messages` or `reminders`. The [`onPush()`](/Notifications/onPush/) handler registered with this key decides what its push shows. Up to 32 lowercase letters, digits and `-`.
- `sensitive` (Boolean): Pushes, and the notification a page of your app shows itself, name only your app, such as "New notification from Chat" for an app named Chat, instead of showing the title and text. On a website, a sensitive push carries no `channel`, `target` or `data`. So no [`onPush()`](/Notifications/onPush/) handler runs for it, a tap opens your service worker's scope (`/` by default), and [`on('click')`](/Notifications/on/) gets `null` for `target` and `data`. A listener added with [`onNotification()`](/Notifications/onNotification/) still gets the whole notification.
- `sensitive` (Boolean): Pushes, and the notification a page of your app shows itself, name only your app, such as "New notification from Chat" for an app named Chat, instead of showing the title and text. On a website, a sensitive push carries no `channel`, `target` or `data`. So no [`onPush()`](/Notifications/onPush/) handler runs for it, a tap opens your site's root, or your service worker's folder when your pages are inside it, and [`on('click')`](/Notifications/on/) gets `null` for `target` and `data`. A listener added with [`onNotification()`](/Notifications/onNotification/) still gets the whole notification.
- `priority` (String): Only `'normal'` is available to apps.
- `ttl` (Number): How many seconds a push may wait for a device that's offline, from 1 to 86400. Defaults to 3600. It doesn't change how long the notification is kept.
- `to` (Object): Send it to the other side of a key-value share instead, as `{ handle }` with the share's handle. See [Notify the other side of a share](#notify-the-other-side-of-a-share).
+1 -1
View File
@@ -11,7 +11,7 @@ Clicks reach your app in two ways:
- **On a website**, from a tap on a push, or on a notification a page of your site showed itself. When a page of your site is open, the tap focuses it and calls its `click` handlers. A page without a `click` handler goes to the notification's `url` instead, which is the `url` your [`onPush()`](/Notifications/onPush/) handler returned, or else the notification's `target`. When no page is open, the tap opens that `url`, and the new page's first `click` handler gets the click.
- **Inside the Puter desktop**, from a tap on a push that Puter shows. Puter opens your app, or brings it to the front, and delivers the click.
A click that arrives before your first `click` handler is added, such as the one that opened your app, goes to that first handler, once. Handlers added after it don't get it, so add your handler early during startup.
A click that arrives before your first `click` handler is added, such as the one that opened your app, goes to that first handler, once. Handlers added after it don't get it, so add your handler early during startup. On a website, a page a tap opened drops the click when no `click` handler is added within 10 seconds of the page loading.
To remove a handler, call [`puter.notifications.off('click', handler)`](/Notifications/on/).
+1 -1
View File
@@ -37,7 +37,7 @@ The `channel` your notifications are sent with. 1 to 32 lowercase letters, digit
A plain `function` or an arrow function. It's called with the notification, in 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` (String): As in the browser's `showNotification()`. A notification sent with a `group` gets a `tag` from it by default, so a later one in the group replaces it on screen.
- `url` (String): A path or URL on your origin, resolved against your worker's scope. Defaults to the notification's `target`. A URL on another origin is replaced with your worker's scope. When no page of your site is open, a tap opens it. When a page is open, the tap focuses that page and goes to [`on('click')`](/Notifications/on/). A page without a `click` handler goes to `url` instead.
- `url` (String): A path or URL on your origin. Defaults to the notification's `target`. A relative path is read from your site's root, or from your worker's folder when your pages are inside it ([requirements](/Notifications/push/#requirements)). A URL on another origin is replaced with that root or folder. When no page of your site is open, a tap opens it. When a page is open, the tap focuses that page and goes to [`on('click')`](/Notifications/on/). A page without a `click` handler goes to `url` instead.
Returning nothing shows the notification's own `title` and `text`. Inside the Puter desktop, the result isn't used, and a push shows the notification's own title and text.
+3 -3
View File
@@ -40,7 +40,7 @@ On a website, pushes arrive on your own domain through a small service worker, a
- **HTTPS.** Browsers only allow service workers on `https://` pages, and on `http://localhost` for testing.
- **A worker on your own origin.** The worker file has to come from the same origin as your pages. A worker on a CDN or another subdomain can't be registered.
- **At the root of your site, by default.** [`enable()`](/Notifications/enable/) registers `/puter-sw.js`. A worker elsewhere, such as `/puter/puter-sw.js`, works when you pass its path as `serviceWorker`. A tap with no page open resolves relative URLs against the worker's folder.
- **At the root of your site, by default.** [`enable()`](/Notifications/enable/) registers `/puter-sw.js`. A worker elsewhere, such as `/puter/puter-sw.js`, works when you pass its path as `serviceWorker`. A tap with no URL opens your site's root, and a relative URL is read from it. When the page that calls [`onPush()`](/Notifications/onPush/) or [`enable()`](/Notifications/enable/) is inside the worker's folder, such as an app at `/app/` with `/app/puter-sw.js`, taps use that folder instead.
- **Served as JavaScript.** The response needs a JavaScript `Content-Type`, such as `text/javascript`, and a `200` status. Browsers refuse a worker that comes back as `text/html` or through a redirect.
[Puter Hosting](/Hosting/) meets all of these when `puter-sw.js` is in the root of the folder you host. With a build tool, put the file in the folder it copies as is, such as `public/` in Vite or Next.js.
@@ -150,7 +150,7 @@ To notify another user, share data with them with `notify: true` and send throug
## When the user taps
On a website, a tap on a push, or on a notification a page of your site showed itself, focuses an open page of your site and calls its [`on('click')`](/Notifications/on/) handlers. With no page open, it opens the handler's `url`, or else the notification's `target`, and the new page's first [`on('click')`](/Notifications/on/) handler gets the click. Inside the Puter desktop, a tap opens your app and hands the click to [`on('click')`](/Notifications/on/).
On a website, a tap on a push, or on a notification a page of your site showed itself, focuses an open page of your site and calls its [`on('click')`](/Notifications/on/) handlers. With no page open, it opens the handler's `url`, or else the notification's `target`, and the new page's first [`on('click')`](/Notifications/on/) handler gets the click. A tap reaches one page, once: when an open page doesn't answer within about half a second, the tap opens its URL instead, and the slow page doesn't get it, unless that page is already at the tap's URL. If that page wasn't loaded through your worker, such as one opened before the worker was installed, the URL opens in a new tab. Inside the Puter desktop, a tap opens your app and hands the click to [`on('click')`](/Notifications/on/).
## Groups and sensitive notifications
@@ -310,5 +310,5 @@ The browser can be allowed to show notifications while the operating system hide
- Say who did what. "Ana shared Q3-report.pdf" reads well as a push. A push that only says there's something new can be hidden as spam on Android.
- The `data` field is handed back on click, and push services can read it, so put ids in it and fetch the rest. Set `sensitive: true` when the title and text shouldn't leave Puter.
- A push carries about 4 KB, with `data` at most 1 KB. A push that doesn't fit has its text shortened.
- A tap opens your domain. The `url` a handler returns has to be on your origin, and a tap on one from another origin opens your worker's folder instead.
- A tap opens your domain. The `url` a handler returns has to be on your origin, and a tap on one from another origin opens your site's root instead, or the worker's folder when your pages are inside it.
- Pushes from your app are billed to the user who receives them. See [What pushes cost](/rate-limits-and-quotas/#what-pushes-cost). Puter's own notifications are free.
+112 -24
View File
@@ -7,14 +7,17 @@ import { enable } from './enable.js';
import { enablePush } from './enablePush.js';
import { request } from './lib/api.js';
import {
CLICKS_STORE,
CLICK_KEEP_MS,
HANDLERS_STORE,
TAP_BASE_PREFIX,
forgetOldClicks,
forgetPushState,
hasOptIn,
idbDelete,
idbGet,
idbPut,
setOptIn,
takeClick,
} from './lib/pushStore.js';
import { ReceiptLog } from './lib/receipts.js';
import { toClick } from './lib/shapes.js';
@@ -26,6 +29,7 @@ import {
webPushState,
webPushSupported,
workerFor,
workerScope,
} from './lib/webPush.js';
import { listDevices } from './listDevices.js';
import { notify } from './notify.js';
@@ -57,6 +61,8 @@ const PUSH_LOCK = 'puter-push';
const HANDLER_TIMEOUT_MS = 5_000;
/** How long an older `push-sw.js`, which never answers, is waited for. */
const WORKER_SHOW_TIMEOUT_MS = 2_000;
/** How long after load a page a tap opened waits for a `click` handler. */
const UNREAD_TAP_MS = 10_000;
/** Sync failures that are the server's or the user's choice, not a fault. */
const QUIET_SYNC_ERRORS = new Set([
'push_device_removed',
@@ -124,20 +130,41 @@ const withinHandlerTime = async (promise) => {
}
};
/**
* Where a tap goes: on this origin, with relative paths read from the worker's
* scope.
*/
export const tapUrl = (url, scope) => {
const base = new URL(scope);
/** Where a tap goes: on `base`'s origin, with relative paths read from it. */
export const tapUrl = (url, base) => {
const from = new URL(base);
try {
const resolved = new URL(url ?? '', base);
return resolved.origin === base.origin ? resolved.href : base.href;
const resolved = new URL(url ?? '', from);
return resolved.origin === from.origin ? resolved.href : from.href;
} catch {
return base.href;
return from.href;
}
};
/** Where taps resolve: the worker's scope if this page is in it, else `/`. */
const tapBaseFor = (worker, pageUrl) => {
const scope = workerScope(worker);
return {
scope,
base: pageUrl.startsWith(scope) ? scope : new URL('/', scope).href,
};
};
/** The tap base a page saved for `scope`, else `scope`. */
const savedTapBase = async (scope) => {
try {
const base = await idbGet(HANDLERS_STORE, TAP_BASE_PREFIX + scope);
if (
typeof base === 'string' &&
new URL(base).origin === new URL(scope).origin
)
return base;
} catch {
// Nothing saved, or IndexedDB is unavailable.
}
return scope;
};
/**
* Hands the notification to `push-sw.js`, which shows it unless another window
* of the app has focus. Resolves whether the worker answered.
@@ -229,6 +256,7 @@ export class NotificationsModule extends EventListener {
#userChange = Promise.resolve();
/** The token whose push state sign-out already ended. */
#forgottenToken = null;
#sweptClicks = false;
/** @param {import('../../index.js').Puter} puter */
constructor(puter) {
@@ -255,7 +283,10 @@ export class NotificationsModule extends EventListener {
]) {
methods[name] = methods[name].bind(this);
}
if (puter.env === 'web') this.#watchUser();
if (puter.env === 'web') {
this.#watchUser();
this.#dropUnreadTap();
}
}
/**
@@ -363,6 +394,10 @@ export class NotificationsModule extends EventListener {
* @internal
*/
_syncPushOnLoad(worker) {
if (!this.#sweptClicks) {
this.#sweptClicks = true;
forgetOldClicks().catch(() => {});
}
if (!this.#syncs.has(worker.url)) {
this.#syncs.set(worker.url, this.#syncIfOptedIn(worker));
}
@@ -558,6 +593,7 @@ export class NotificationsModule extends EventListener {
#useWorker(worker) {
this.#worker = worker;
if (this.puter.env === 'web') void this.#saveTapBase(worker);
void (async () => {
try {
const registration =
@@ -572,6 +608,18 @@ export class NotificationsModule extends EventListener {
})();
}
async #saveTapBase(worker) {
try {
const { scope, base } = tapBaseFor(
worker,
globalThis.location.href,
);
await idbPut(HANDLERS_STORE, TAP_BASE_PREFIX + scope, base);
} catch {
// Taps then open the worker's scope.
}
}
/** Whether this page shows a browser notification itself while it is away. */
#showsOwn() {
return (
@@ -879,7 +927,7 @@ export class NotificationsModule extends EventListener {
uid: notification.uid,
target: sensitive ? null : notification.target,
data: sensitive ? null : notification.data,
url: tapUrl(url, scope),
url: tapUrl(url, await savedTapBase(scope)),
},
},
};
@@ -904,21 +952,35 @@ export class NotificationsModule extends EventListener {
if (event.data?.type !== WORKER_CLICK_MESSAGE) return;
const click = toClick(event.data.notification);
if (!click || !this._hasListeners('click')) return;
if (event.data.id !== undefined) {
event.source?.postMessage?.({
type: WORKER_CLICK_ACK_MESSAGE,
id: event.data.id,
});
const take = () => {
if (event.data.id !== undefined) {
event.source?.postMessage?.({
type: WORKER_CLICK_ACK_MESSAGE,
id: event.data.id,
});
}
this.emit('click', click);
};
if (event.data.kept !== true) {
take();
return;
}
this.emit('click', click);
// A tap the worker kept runs only if this page takes it first.
void (async () => {
let claimed = false;
try {
claimed = !!(await takeClick(click.uid, event.data.id));
} catch {
// Left to the worker, which opens it when nobody acks.
}
if (claimed) take();
})();
});
container?.startMessages?.();
const url = globalThis.location
? new URL(globalThis.location.href)
: null;
const uid = url?.searchParams.get(CLICK_PARAM);
const uid = this.#tapInUrl();
if (!uid) return;
const url = new URL(globalThis.location.href);
url.searchParams.delete(CLICK_PARAM);
globalThis.history?.replaceState?.(
globalThis.history.state,
@@ -927,8 +989,11 @@ export class NotificationsModule extends EventListener {
);
void (async () => {
try {
const click = toClick(await idbGet(CLICKS_STORE, uid));
await idbDelete(CLICKS_STORE, uid);
const kept = await takeClick(uid);
const click =
kept?.at > Date.now() - CLICK_KEEP_MS
? toClick(kept)
: null;
if (click) firstHandler(click);
} catch (error) {
console.warn(
@@ -938,6 +1003,29 @@ export class NotificationsModule extends EventListener {
}
})();
}
/** The tap this page was opened for, from its URL. */
#tapInUrl() {
try {
return new URL(globalThis.location.href).searchParams.get(
CLICK_PARAM,
);
} catch {
return null;
}
}
/** A tap that opened this page is dropped when no `click` handler reads it. */
#dropUnreadTap() {
const uid = this.#tapInUrl();
if (!uid) return;
const drop = () =>
setTimeout(() => {
if (!this.#listening) takeClick(uid).catch(() => {});
}, UNREAD_TAP_MS);
if (globalThis.document?.readyState === 'complete') drop();
else globalThis.addEventListener?.('load', drop, { once: true });
}
}
/**
@@ -7,6 +7,10 @@ export const CLICKS_STORE = 'clicks';
// stays at the version workers already installed open.
const OPT_IN_PREFIX = 'puter:opt-in:';
export const SERVER_KEY_ENTRY = 'puter:server-key';
/** Where a tap without a URL goes, per worker scope; read by `push-sw.js`. */
export const TAP_BASE_PREFIX = 'puter:tap-base:';
/** How long a kept tap waits for its page, as in `push-sw.js`. */
export const CLICK_KEEP_MS = 5 * 60 * 1000;
const open = (idb) =>
new Promise((resolve, reject) => {
@@ -60,3 +64,31 @@ export const forgetPushState = async (idb = globalThis.indexedDB) => {
);
await run(idb, CLICKS_STORE, 'readwrite', (s) => s.clear());
};
/** Takes tap `uid` (only the copy posted as `id`, if given) in one transaction. */
export const takeClick = async (uid, id, idb = globalThis.indexedDB) => {
const matches = (kept) => !!kept && (id === undefined || kept.id === id);
const kept = await run(idb, CLICKS_STORE, 'readwrite', (s) => {
const req = s.get(uid);
req.addEventListener('success', () => {
if (matches(req.result)) s.delete(uid);
});
return req;
});
return matches(kept) ? kept : null;
};
/** Removes kept taps older than `CLICK_KEEP_MS`. */
export const forgetOldClicks = (idb = globalThis.indexedDB) => {
const cutoff = Date.now() - CLICK_KEEP_MS;
return run(idb, CLICKS_STORE, '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;
});
};
@@ -60,6 +60,10 @@ const scriptUrl = (worker) => {
}
};
/** The scope `worker` registers at: its own, else its script's folder. */
export const workerScope = (worker) =>
new URL(worker.scope ?? './', scriptUrl(worker)).href;
const workerConflict = (worker, other) =>
new PuterJSError(
`Another service worker (${other.scriptURL}) runs where ${worker.url} would. ` +
@@ -121,7 +125,7 @@ const siteWorkerAt = async (nav, worker) => {
let scope;
let existing;
try {
scope = new URL('./', scriptUrl(worker)).href;
scope = workerScope(worker);
existing = await nav.serviceWorker.getRegistration(scope);
} catch {
return null;
@@ -18,6 +18,21 @@ vi.mock('./lib/pushStore.js', () => {
HANDLERS_STORE: 'handlers',
CLICKS_STORE: 'clicks',
SERVER_KEY_ENTRY: 'puter:server-key',
TAP_BASE_PREFIX: 'puter:tap-base:',
CLICK_KEEP_MS: 5 * 60 * 1000,
takeClick: stored(async (uid, id) => {
const kept = store.get(`clicks:${uid}`);
if (!kept || (id !== undefined && kept.id !== id)) return null;
store.delete(`clicks:${uid}`);
return kept;
}),
forgetOldClicks: stored(async () => {
const cutoff = Date.now() - 5 * 60 * 1000;
for (const [key, value] of [...store.entries()]) {
if (key.startsWith('clicks:') && !(value?.at > cutoff))
store.delete(key);
}
}),
idbGet: stored(async (s, k) => store.get(`${s}:${k}`)),
idbPut: stored(async (s, k, v) => void store.set(`${s}:${k}`, v)),
idbDelete: stored(async (s, k) => void store.delete(`${s}:${k}`)),
@@ -1000,6 +1015,52 @@ describe('onPush', () => {
expect(store.size).toBe(0);
});
describe('tap base', () => {
const TAP_BASE = 'handlers:puter:tap-base:';
it.each([
[
'the worker`s folder for a page inside it',
`${ORIGIN}/app/index.html`,
'/app/puter-sw.js',
`${ORIGIN}/app/`,
`${ORIGIN}/app/`,
],
[
'the site`s root for a page outside the folder',
`${ORIGIN}/`,
'/puter/puter-sw.js',
`${ORIGIN}/puter/`,
`${ORIGIN}/`,
],
[
'the root for the default worker',
`${ORIGIN}/chat`,
undefined,
`${ORIGIN}/`,
`${ORIGIN}/`,
],
])('saves %s', async (_, href, serviceWorker, scope, base) => {
globalThis.location = { href, origin: ORIGIN };
const { module } = makeModule();
await module.onPush('chat', () => {}, { serviceWorker });
await flush();
expect(store.get(`${TAP_BASE}${scope}`)).toBe(base);
});
it('is saved by enable() too', async () => {
withBrowser(
makeNav({
active: { scriptURL: `${ORIGIN}/puter/puter-sw.js` },
}),
);
server();
const { module } = makeModule();
await module.enable({ serviceWorker: '/puter/puter-sw.js' });
expect(store.get(`${TAP_BASE}${ORIGIN}/puter/`)).toBe(`${ORIGIN}/`);
});
});
it('starts one feed for every key', async () => {
const { module } = makeModule();
await module.onPush('chat', () => {});
@@ -1101,6 +1162,21 @@ describe('onPush', () => {
expect(theirs.unsubscribe).not.toHaveBeenCalled();
});
it('removes taps kept longer than five minutes', async () => {
store.set('clicks:old', {
uid: 'old',
at: Date.now() - 6 * 60 * 1000,
});
store.set('clicks:fresh', { uid: 'fresh', at: Date.now() });
withBrowser(makeNav());
server();
const { module } = makeModule();
await module.onPush('chat', () => {});
await flush();
expect(store.has('clicks:old')).toBe(false);
expect(store.has('clicks:fresh')).toBe(true);
});
it('syncs once for every onPush on the page when the user turned push on', async () => {
store.set(OPT_IN, true);
withBrowser(makeNav({ existing: makeSubscription() }));
@@ -1389,6 +1465,20 @@ describe('onPush', () => {
);
});
it('reads the tap URL from the saved tap base', async () => {
away();
nav.registration.scope = `${ORIGIN}/puter/`;
const { module, feed } = makeModule();
await module.onPush('chat', () => ({ url: 'chat/1' }), {
serviceWorker: '/puter/puter-sw.js',
});
await feed.deliver({ uid: 'n1', channel: 'chat', title: 'Hi' });
expect(
nav.registration.showNotification.mock.calls[0][1].data.puter
.url,
).toBe(`${ORIGIN}/chat/1`);
});
it('reads the tap URL from the worker`s scope', async () => {
away();
nav.registration.scope = `${ORIGIN}/app/`;
@@ -1587,6 +1677,139 @@ describe('clicks from push-sw.js', () => {
nav.post(tap, source);
expect(source.postMessage).not.toHaveBeenCalled();
});
const keptTap = {
type: 'puter-notification-click',
id: 'click-1',
kept: true,
notification: { uid: 'n1', target: '/c', data: null },
};
const clickModule = () => {
const nav = makeNav();
withBrowser(nav);
const { module } = makeModule();
const handler = vi.fn();
module.on('click', handler);
return { nav, handler, source: { postMessage: vi.fn() } };
};
it('takes a tap the worker kept for it, then acknowledges it', async () => {
const { nav, handler, source } = clickModule();
store.set('clicks:n1', { uid: 'n1', at: Date.now(), id: 'click-1' });
nav.post(keptTap, source);
await flush();
expect(store.has('clicks:n1')).toBe(false);
expect(handler).toHaveBeenCalledTimes(1);
expect(source.postMessage).toHaveBeenCalledWith({
type: 'puter-notification-click-ack',
id: 'click-1',
});
});
it('leaves a tap the worker already took back', async () => {
const { nav, handler, source } = clickModule();
nav.post(keptTap, source);
await flush();
expect(handler).not.toHaveBeenCalled();
expect(source.postMessage).not.toHaveBeenCalled();
});
it('leaves a tap the worker kept again for the page it opened', async () => {
const { nav, handler, source } = clickModule();
store.set('clicks:n1', { uid: 'n1', at: Date.now() });
nav.post(keptTap, source);
await flush();
expect(handler).not.toHaveBeenCalled();
expect(store.has('clicks:n1')).toBe(true);
});
it('leaves a kept tap to the worker when IndexedDB fails here', async () => {
const { nav, handler, source } = clickModule();
store.set('clicks:n1', { uid: 'n1', at: Date.now(), id: 'click-1' });
storage.broken = true;
nav.post(keptTap, source);
await flush();
expect(handler).not.toHaveBeenCalled();
expect(source.postMessage).not.toHaveBeenCalled();
});
it('runs the tap once when the worker posts it twice', async () => {
const { nav, handler, source } = clickModule();
store.set('clicks:n1', { uid: 'n1', at: Date.now(), id: 'click-1' });
nav.post(keptTap, source);
nav.post(keptTap, source);
await flush();
expect(handler).toHaveBeenCalledTimes(1);
});
describe('in the URL of a page a tap opened', () => {
beforeEach(() => {
globalThis.location = {
href: `${ORIGIN}/chat?puter.notification=n1`,
origin: ORIGIN,
};
});
it('goes to the first click handler, once', async () => {
store.set('clicks:n1', {
uid: 'n1',
target: '/c',
data: 1,
at: Date.now(),
});
const { handler } = clickModule();
await flush();
expect(handler).toHaveBeenCalledWith({
uid: 'n1',
target: '/c',
data: 1,
});
expect(store.has('clicks:n1')).toBe(false);
});
it('is dropped once older than five minutes', async () => {
store.set('clicks:n1', {
uid: 'n1',
at: Date.now() - 6 * 60 * 1000,
});
const { handler } = clickModule();
await flush();
expect(handler).not.toHaveBeenCalled();
expect(store.has('clicks:n1')).toBe(false);
});
it('is removed when no click handler comes within 10 seconds of load', async () => {
vi.useFakeTimers();
globalThis.document = { readyState: 'complete' };
try {
store.set('clicks:n1', { uid: 'n1', at: Date.now() });
makeModule();
await vi.advanceTimersByTimeAsync(9_000);
expect(store.has('clicks:n1')).toBe(true);
await vi.advanceTimersByTimeAsync(1_000);
expect(store.has('clicks:n1')).toBe(false);
} finally {
delete globalThis.document;
}
});
it('stays for a click handler added within 10 seconds of load', async () => {
vi.useFakeTimers();
globalThis.document = { readyState: 'complete' };
try {
store.set('clicks:n1', { uid: 'n1', at: Date.now() });
withBrowser(makeNav());
const { module } = makeModule();
await vi.advanceTimersByTimeAsync(5_000);
const handler = vi.fn();
module.on('click', handler);
await vi.advanceTimersByTimeAsync(10_000);
expect(handler).toHaveBeenCalledTimes(1);
} finally {
delete globalThis.document;
}
});
});
});
describe('enable', () => {
+80 -24
View File
@@ -21,8 +21,11 @@
const CLICKS = 'clicks';
const HANDLER_TIMEOUT_MS = 5000;
const CLICK_KEEP_MS = 5 * 60 * 1000;
// Browsers allow openWindow only shortly after the click.
const CLICK_ACK_BUDGET_MS = 600;
// Firefox allows openWindow for 1 s after the click; this leaves room for it.
const CLICK_OPEN_BUDGET_MS = 800;
// Saved by the page; the worker's scope is used when there is none.
const TAP_BASE_PREFIX = 'puter:tap-base:';
const DEFAULT_TITLE = 'New notification';
const CHALLENGE_MESSAGE = 'puter-push-challenge';
const CLICK_MESSAGE = 'puter-notification-click';
@@ -144,20 +147,32 @@
return shown;
}
// Taps only ever open this origin; relative paths are read from the worker's scope.
function sameOriginUrl (url) {
async function tapBase () {
const scope = self.registration.scope ?? `${self.location.origin}/`;
try {
const parsed = new URL(url ?? '', scope);
return parsed.origin === self.location.origin ? parsed.href : scope;
const base = await idb(HANDLERS, 'readonly', (s) => s.get(TAP_BASE_PREFIX + scope));
if ( typeof base === 'string' && new URL(base).origin === self.location.origin ) return base;
} catch {
return scope;
// nothing saved, or IndexedDB is unavailable
}
return scope;
}
// Taps only ever open this origin; relative paths are read from the tap base.
async function sameOriginUrl (url) {
const base = await tapBase();
try {
const parsed = new URL(url ?? '', base);
return parsed.origin === self.location.origin ? parsed.href : base;
} catch {
return base;
}
}
async function windows () {
async function windows ({ topLevel = false } = {}) {
const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
return all.filter((c) => {
if ( topLevel && c.frameType === 'nested' ) return false;
try {
return new URL(c.url).origin === self.location.origin;
} catch {
@@ -184,7 +199,7 @@
uid: notification.uid,
target: notification.target,
data: notification.data,
url: sameOriginUrl(url),
url: await sameOriginUrl(url),
},
};
try {
@@ -201,7 +216,7 @@
// A page that got a notification live while away; held back while another window of the app has focus.
async function showForPage (message, source) {
for ( const win of await windows() ) {
for ( const win of await windows({ topLevel: true }) ) {
if ( win.focused && win.id !== source?.id ) return false;
}
const options = message.options && typeof message.options === 'object' ? message.options : {};
@@ -227,6 +242,33 @@
});
}
// One readwrite get+delete, so only the page or the worker gets the tap.
async function claimClick (uid, id) {
const matches = (kept) => !!kept && (id === undefined || kept.id === id);
const kept = await idb(CLICKS, 'readwrite', (s) => {
const req = s.get(uid);
req.addEventListener('success', () => {
if ( matches(req.result) ) s.delete(uid);
});
return req;
});
return matches(kept) ? kept : null;
}
// Like claimClick, but keeps the copy, without its id, for the page opened next.
async function reclaimClick (uid, id) {
const kept = await idb(CLICKS, 'readwrite', (s) => {
const req = s.get(uid);
req.addEventListener('success', () => {
if ( req.result?.id !== id ) return;
const { id: _, ...copy } = req.result;
s.put({ ...copy, at: Date.now() }, uid);
});
return req;
});
return kept?.id === id ? kept : null;
}
function waitForClickAck (id, timeoutMs) {
return new Promise((resolve) => {
const timer = setTimeout(() => {
@@ -241,37 +283,49 @@
});
}
// Kept for the page the tap opens, which reads it from the URL.
async function keepClick (click) {
if ( ! click.uid ) return;
// Kept for the page that claims it: an open one by `id`, or one the tap opens.
async function keepClick (click, id) {
if ( ! click.uid ) return false;
try {
await forgetOldClicks();
await idb(CLICKS, 'readwrite', (s) => s.put({ ...click, at: Date.now() }, click.uid));
await idb(CLICKS, 'readwrite', (s) => s.put({ ...click, at: Date.now(), ...(id ? { id } : {}) }, click.uid));
return true;
} catch ( err ) {
console.warn('[puter] could not keep a tap', err);
return false;
}
}
// An open window with a click handler takes the tap; otherwise one opens its URL.
async function routeClick (puter, startedAt = Date.now()) {
const click = { uid: puter.uid, target: puter.target ?? null, data: puter.data ?? null };
const url = sameOriginUrl(puter.url);
const open = await windows();
const url = await sameOriginUrl(puter.url);
const open = await windows({ topLevel: true });
const win = open.find((c) => c.url === url) ?? open.find((c) => c.focused) ?? open[0];
if ( win ) {
const id = `click-${++nextClickId}`;
const acked = waitForClickAck(id, Math.max(0, startedAt + CLICK_ACK_BUDGET_MS - Date.now()));
win.postMessage({ type: CLICK_MESSAGE, id, notification: click });
const id = `click-${Date.now()}-${++nextClickId}`;
const kept = await keepClick(click, id);
const postedAt = Date.now();
const deadline = Math.min(postedAt + CLICK_ACK_BUDGET_MS, startedAt + CLICK_OPEN_BUDGET_MS);
const acked = waitForClickAck(id, Math.max(0, deadline - postedAt));
win.postMessage({ type: CLICK_MESSAGE, id, notification: click, ...(kept ? { kept: true } : {}) });
try {
await win.focus();
} catch {
// focus is refused once the click's window has passed
}
if ( await acked ) return;
// Already there: reloading would only lose what the user had open.
if ( await acked ) {
// A page that took the tap removed it; an older page leaves it.
if ( kept ) await claimClick(click.uid, id).catch(() => null);
return;
}
// Already there: the page may still take its copy; reloading would lose its state.
if ( win.url === url ) return;
// A failed claim counts as the page's, so the tap runs at most once.
if ( kept && ! await reclaimClick(click.uid, id).catch(() => null) ) return;
if ( ! kept ) await keepClick(click);
} else {
await keepClick(click);
}
await keepClick(click);
const opened = new URL(url);
if ( click.uid ) opened.searchParams.set(CLICK_PARAM, click.uid);
if ( win?.navigate ) {
@@ -285,10 +339,12 @@
await self.clients.openWindow(opened.href);
}
const forgetOldClicksQuietly = () => forgetOldClicks().catch(() => {});
self.addEventListener('push', (event) => {
const message = readMessage(event.data);
if ( ! message ) return;
event.waitUntil(showPush(message));
event.waitUntil(Promise.all([showPush(message), forgetOldClicksQuietly()]));
});
self.addEventListener('message', (event) => {
@@ -314,7 +370,7 @@
if ( ! puter || typeof puter !== 'object' ) return;
const startedAt = Date.now();
event.notification.close();
event.waitUntil(routeClick(puter, startedAt));
event.waitUntil(Promise.all([routeClick(puter, startedAt), forgetOldClicksQuietly()]));
});
self.addEventListener('pushsubscriptionchange', (event) => {
+215 -5
View File
@@ -10,13 +10,14 @@ const SOURCE = readFileSync(
);
const ORIGIN = 'https://app.example';
/** Enough of IndexedDB for get/put and a cursor walk. */
/** Enough of IndexedDB for this worker; logs transactions, fails while `broken`. */
const fakeIndexedDB = (stores) => {
const request = (run) => {
const req = { listeners: [] };
req.addEventListener = (_t, fn) => req.listeners.push(fn);
queueMicrotask(() => {
try {
if (stores.broken) throw new Error('IndexedDB is not available');
req.result = run(req);
req.onsuccess?.();
for (const fn of req.listeners) fn();
@@ -32,13 +33,32 @@ const fakeIndexedDB = (stores) => {
return {
get: (key) => request(() => map.get(key)),
put: (value, key) => request(() => void map.set(key, value)),
openCursor: () => request(() => null),
delete: (key) => request(() => void map.delete(key)),
openCursor: () => {
const keys = [...map.keys()];
const cursorAt = (req, i) =>
i < keys.length
? {
value: map.get(keys[i]),
delete: () => map.delete(keys[i]),
continue: () =>
queueMicrotask(() => {
req.result = cursorAt(req, i + 1);
for (const fn of req.listeners) fn();
}),
}
: null;
return request((req) => cursorAt(req, 0));
},
};
};
return {
open: () => {
const req = request(() => ({
transaction: (name) => ({ objectStore: () => objectStore(name) }),
transaction: (name, mode) => {
stores.log.push({ name, mode });
return { objectStore: () => objectStore(name) };
},
close: () => {},
createObjectStore: () => {},
}));
@@ -60,12 +80,19 @@ const windowClient = (url, over = {}) => ({
const loadWorker = ({
handlers = {},
clicks = {},
windows = [],
matchAllDelayMs = 0,
scope: registrationScope = `${ORIGIN}/`,
} = {}) => {
const listeners = {};
const shown = [];
const stores = { handlers: new Map(Object.entries(handlers)) };
const stores = {
handlers: new Map(Object.entries(handlers)),
clicks: new Map(Object.entries(clicks)),
log: [],
broken: false,
};
const scope = {
location: { origin: ORIGIN },
addEventListener: (type, fn) => {
@@ -78,7 +105,10 @@ const loadWorker = ({
}),
},
clients: {
matchAll: vi.fn(async () => windows),
matchAll: vi.fn(async () => {
if (matchAllDelayMs) await wait(matchAllDelayMs);
return windows;
}),
openWindow: vi.fn(async () => null),
},
};
@@ -123,6 +153,8 @@ const loadWorker = ({
};
const pushData = (payload) => ({ json: () => payload });
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const TAP_BASE = 'puter:tap-base:';
describe('push-sw push', () => {
it('leaves a push without a Puter message to the app', async () => {
@@ -261,6 +293,60 @@ describe('push-sw push', () => {
expect(worker.shown[0].options.data.puter.url).toBe(`${ORIGIN}/app/`);
});
it('opens the site`s root for a folder worker when the page saved it as the tap base', async () => {
const worker = loadWorker({
scope: `${ORIGIN}/puter/`,
handlers: {
[`${TAP_BASE}${ORIGIN}/puter/`]: `${ORIGIN}/`,
chat: '() => ({ url: "chat/1" })',
},
});
await worker.fire('push', {
data: pushData({ puter: { uid: 'n1', title: 'Hi' } }),
});
await worker.fire('push', {
data: pushData({ puter: { uid: 'n2', title: 'Hi', channel: 'chat' } }),
});
expect(worker.shown.map((n) => n.options.data.puter.url)).toEqual([
`${ORIGIN}/`,
`${ORIGIN}/chat/1`,
]);
});
it('keeps to the scope when the saved tap base is on another origin', async () => {
const worker = loadWorker({
scope: `${ORIGIN}/puter/`,
handlers: { [`${TAP_BASE}${ORIGIN}/puter/`]: 'https://elsewhere.example/' },
});
await worker.fire('push', {
data: pushData({ puter: { uid: 'n1', title: 'Hi' } }),
});
expect(worker.shown[0].options.data.puter.url).toBe(`${ORIGIN}/puter/`);
});
it('removes taps kept longer than five minutes', async () => {
const now = Date.now();
const worker = loadWorker({
clicks: {
old: { uid: 'old', at: now - 6 * 60 * 1000 },
fresh: { uid: 'fresh', at: now },
},
});
await worker.fire('push', {
data: pushData({ puter: { uid: 'n1', title: 'Hi' } }),
});
expect([...worker.stores.clicks.keys()]).toEqual(['fresh']);
});
it('hands a challenge to nested frames too', async () => {
const nested = windowClient(`${ORIGIN}/embed`, { frameType: 'nested' });
const worker = loadWorker({ windows: [nested] });
await worker.fire('push', {
data: pushData({ puter: { type: 'challenge', challenge: 'c.sig' } }),
});
expect(nested.postMessage).toHaveBeenCalledWith({ type: 'puter-push-challenge', challenge: 'c.sig' });
});
it('shows a challenge and hands it to this origin`s windows', async () => {
const mine = windowClient(`${ORIGIN}/`);
const other = windowClient('https://elsewhere.example/');
@@ -294,6 +380,7 @@ describe('push-sw notificationclick', () => {
type: 'puter-notification-click',
id: expect.any(String),
notification: { uid: 'n1', target: '/chat', data: { id: 1 } },
kept: true,
});
expect(open.focus).toHaveBeenCalled();
expect(open.navigate).not.toHaveBeenCalled();
@@ -340,6 +427,121 @@ describe('push-sw notificationclick', () => {
expect(worker.stores.clicks.get('n1')).toMatchObject({ uid: 'n1', target: '/chat' });
});
it('removes the copy an older page acknowledged without taking it', async () => {
const open = windowClient(`${ORIGIN}/chat`);
const worker = loadWorker({ windows: [open] });
await worker.tap({ uid: 'n1', target: '/chat', data: null, url: `${ORIGIN}/chat` }, acks(worker));
expect(worker.stores.clicks.has('n1')).toBe(false);
});
it('skips nested frames and opens a window for the tap', async () => {
const nested = windowClient(`${ORIGIN}/chat`, { frameType: 'nested', focused: true });
const worker = loadWorker({ windows: [nested] });
await worker.tap({ uid: 'n1', target: '/chat', data: null, url: `${ORIGIN}/chat` }, acks(worker));
expect(nested.postMessage).not.toHaveBeenCalled();
expect(worker.scope.clients.openWindow).toHaveBeenCalledWith(`${ORIGIN}/chat?puter.notification=n1`);
});
it('removes taps kept longer than five minutes', async () => {
const worker = loadWorker({ clicks: { old: { uid: 'old', at: Date.now() - 6 * 60 * 1000 } } });
await worker.tap({ uid: 'n1', target: '/chat', data: null, url: `${ORIGIN}/chat` });
expect([...worker.stores.clicks.keys()]).toEqual(['n1']);
});
describe('delivers a tap once', () => {
/** A page that claims the kept tap after `claimAfter` ms and acks `ackAfter` ms after that. */
const slowPage = (worker, ran, { claimAfter, ackAfter = 0 }) => (win, message) => {
setTimeout(() => {
const kept = worker.stores.clicks.get(message.notification.uid);
if (message.kept) {
if (kept?.id !== message.id) return;
worker.stores.clicks.delete(message.notification.uid);
}
ran.push(message.notification.uid);
setTimeout(() => acks(worker)(win, message), ackAfter);
}, claimAfter);
};
/** Handler runs: in the open page, plus one for a page opened or navigated with the tap kept for it. */
const deliveries = (worker, open, ran) => {
const reopened = open.navigate.mock.calls.length + worker.scope.clients.openWindow.mock.calls.length;
return ran.length + (reopened && worker.stores.clicks.has('n1') ? 1 : 0);
};
const TAP = { uid: 'n1', target: '/chat', data: null, url: `${ORIGIN}/chat` };
it('when the page claims it after the worker gave up', async () => {
const open = windowClient(`${ORIGIN}/inbox`);
const worker = loadWorker({ windows: [open] });
const ran = [];
await worker.tap(TAP, slowPage(worker, ran, { claimAfter: 900 }));
await wait(1_000);
expect(open.navigate).toHaveBeenCalledWith(`${ORIGIN}/chat?puter.notification=n1`);
expect(deliveries(worker, open, ran)).toBe(1);
});
it('when the page is already at the tap`s URL and answers late', async () => {
const open = windowClient(`${ORIGIN}/chat`);
const worker = loadWorker({ windows: [open] });
const ran = [];
await worker.tap(TAP, slowPage(worker, ran, { claimAfter: 700 }));
await wait(800);
expect(ran).toEqual(['n1']);
expect(open.navigate).not.toHaveBeenCalled();
expect(worker.scope.clients.openWindow).not.toHaveBeenCalled();
});
it('when IndexedDB fails as the worker takes the tap back', async () => {
const open = windowClient(`${ORIGIN}/inbox`);
const worker = loadWorker({ windows: [open] });
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
await worker.tap(TAP, () => {
worker.stores.broken = true;
});
warn.mockRestore();
expect(open.navigate).not.toHaveBeenCalled();
expect(worker.scope.clients.openWindow).not.toHaveBeenCalled();
});
it('taking it back and keeping it for the next page in one transaction', async () => {
const open = windowClient(`${ORIGIN}/inbox`);
const worker = loadWorker({ windows: [open] });
let posted = 0;
await worker.tap(TAP, () => {
posted = worker.stores.log.length;
});
await wait(10);
const after = worker.stores.log.slice(posted).filter((t) => t.name === 'clicks');
expect(after).toEqual([{ name: 'clicks', mode: 'readwrite' }]);
expect(open.navigate).toHaveBeenCalledWith(`${ORIGIN}/chat?puter.notification=n1`);
expect(worker.stores.clicks.get('n1')).toEqual({
uid: 'n1',
target: '/chat',
data: null,
at: expect.any(Number),
});
});
it('when the page claimed it but its ack came too late', async () => {
const open = windowClient(`${ORIGIN}/inbox`);
const worker = loadWorker({ windows: [open] });
const ran = [];
await worker.tap(TAP, slowPage(worker, ran, { claimAfter: 100, ackAfter: 800 }));
await wait(900);
expect(ran).toEqual(['n1']);
expect(open.navigate).not.toHaveBeenCalled();
expect(worker.scope.clients.openWindow).not.toHaveBeenCalled();
expect(deliveries(worker, open, ran)).toBe(1);
});
it('waiting for the ack from the post, when finding the windows was slow', async () => {
const open = windowClient(`${ORIGIN}/inbox`);
const worker = loadWorker({ windows: [open], matchAllDelayMs: 300 });
const ran = [];
await worker.tap(TAP, slowPage(worker, ran, { claimAfter: 350 }));
expect(ran).toEqual(['n1']);
expect(open.navigate).not.toHaveBeenCalled();
});
});
it('ignores the app`s own notifications', async () => {
const worker = loadWorker();
await worker.fire('notificationclick', { notification: { data: { mine: true }, close: vi.fn() } });
@@ -380,6 +582,14 @@ describe('push-sw notifications a page shows', () => {
expect(worker.shown).toEqual([]);
});
it('shows it while only a nested frame of the app has focus', async () => {
const away = windowClient(`${ORIGIN}/`);
const worker = loadWorker({
windows: [away, windowClient(`${ORIGIN}/embed`, { focused: true, frameType: 'nested' })],
});
expect(await show(worker, away)).toEqual({ shown: true });
});
it('ignores other messages', async () => {
const worker = loadWorker();
await worker.fire('message', { data: { type: 'something-else' } });