Alert Users When Shared Data Changes
In Send Live Updates to Other Users, a scorekeeper shares a game with fans, and each fan sees the score change while their page is open. Most fans won't keep the page open for a whole game. They want an alert when the game ends, or when a team scores.
This recipe adds that. Each fan's app subscribes to the game with a handler that
Puter runs for the fan, even while your app is closed. The handler decides
whether a change is worth an alert, and calls
notify() for the fan. The same pattern fits any
shared data with changes worth an alert, such as an order's status, a price, or
a task assigned to someone.
This recipe uses the prefix, handle and label from
Send Live Updates to Other Users.
Your site also needs to serve /puter-sw.js, as in
Push notifications for your app.
Mark the End of the Game
The scorekeeper writes the score as before, and adds final: true when the game
ends:
await puter.kv.set(`${prefix}score`, { home: 2, away: 1 });
await puter.kv.set(`${prefix}score`, { home: 2, away: 1, final: true });
Publish the Handler
A handler is a function your app publishes once, under a name, with
puter.events.handlers.publish(). Puter runs it in your
app's events worker, where user is the fan the
subscription belongs to. This one alerts on the final score, and on every goal
for fans who asked for that:
await puter.events.handlers.publish('alertScore', async ({ event, user, ctx }) => {
const score = event.value;
if (event.op !== 'set' || event.key !== 'score' || !score) return;
if (!score.final && ctx.alerts !== 'goals') return;
await user.notifications.notify({
title: score.final ? `Final: ${ctx.label}` : ctx.label,
text: `${score.home} - ${score.away}`,
channel: 'scores',
target: `/games/${ctx.handle}`,
});
});
Only the account that owns the app can publish, so run this once yourself, signed in to your site as the owner. The browser console is enough. Publishing the same source again does nothing.
The handler can't use variables from around it, because Puter runs it later,
somewhere else. Everything it needs comes from event, user, and ctx, the
values each subscription is created with.
Say What an Alert Shows
On every page load, register a handler for the scores channel with
puter.notifications.onPush():
await puter.notifications.onPush('scores', (n) => ({
title: n.title,
body: n.text ?? '',
url: n.target ?? '/',
}));
While your app is open, the alert reaches this handler in the page instead of being pushed. While it's closed, the same handler runs in your service worker to decide what the push shows.
Follow a Game
The fan subscribes to the game's score key with
puter.events.onPersistent(). A persistent
subscription is stored on the fan's account and keeps running after they close
your app. The fan's choice of alerts goes in context, which the handler gets
as ctx:
async function followGame (alerts) {
const subject = `kv:${handle}:score`;
const subs = await puter.events.list();
for (const sub of subs) {
if (sub.subject === subject && sub.handlerName === 'alertScore') {
await puter.events.unsubscribe(sub.subId);
}
}
const options = {
subject,
handlerName: 'alertScore',
includeValue: true,
context: { alerts, label, handle },
};
try {
await puter.events.onPersistent(options);
} catch (error) {
if (error.code !== 'events_background_consent_required') throw error;
if (!await puter.perms.request('backgroundEvents')) return false;
await puter.events.onPersistent(options);
}
return true;
}
The context is saved once, when the subscription is created. To change the
fan's choice, the function removes their old subscription with
puter.events.unsubscribe() and creates a new one.
Running a handler while the user is away needs the user's consent, which
puter.perms.request() asks for with the backgroundEvents
resource.
Give the fan two buttons. Browsers only show their permission prompt from a
click, so each one calls puter.notifications.enable()
first:
for (const alerts of ['final', 'goals']) {
document.getElementById(`alert-${alerts}`).addEventListener('click', async () => {
const pushes = await puter.notifications.enable();
const following = await followGame(alerts);
console.log(pushes && following ? `Alerts on: ${alerts}` : 'Alerts are off');
});
}
Stop Following
When the game is over, or the fan presses an "Unfollow" button, remove the subscription:
const subs = await puter.events.list();
for (const sub of subs) {
if (sub.subject === `kv:${handle}:score` && sub.handlerName === 'alertScore') {
await puter.events.unsubscribe(sub.subId);
}
}
When the scorekeeper stops sharing the game, the fan's subscription stops on its own.
Full Code
The handler, published once by the app's owner:
await puter.events.handlers.publish('alertScore', async ({ event, user, ctx }) => {
const score = event.value;
if (event.op !== 'set' || event.key !== 'score' || !score) return;
if (!score.final && ctx.alerts !== 'goals') return;
await user.notifications.notify({
title: score.final ? `Final: ${ctx.label}` : ctx.label,
text: `${score.home} - ${score.away}`,
channel: 'scores',
target: `/games/${ctx.handle}`,
});
});
The fan's page:
await puter.notifications.onPush('scores', (n) => ({
title: n.title,
body: n.text ?? '',
url: n.target ?? '/',
}));
async function followGame (alerts) {
const subject = `kv:${handle}:score`;
const subs = await puter.events.list();
for (const sub of subs) {
if (sub.subject === subject && sub.handlerName === 'alertScore') {
await puter.events.unsubscribe(sub.subId);
}
}
const options = {
subject,
handlerName: 'alertScore',
includeValue: true,
context: { alerts, label, handle },
};
try {
await puter.events.onPersistent(options);
} catch (error) {
if (error.code !== 'events_background_consent_required') throw error;
if (!await puter.perms.request('backgroundEvents')) return false;
await puter.events.onPersistent(options);
}
return true;
}
for (const alerts of ['final', 'goals']) {
document.getElementById(`alert-${alerts}`).addEventListener('click', async () => {
const pushes = await puter.notifications.enable();
const following = await followGame(alerts);
console.log(pushes && following ? `Alerts on: ${alerts}` : 'Alerts are off');
});
}
The worker, at /puter-sw.js:
importScripts('https://js.puter.com/push-sw.js');