Push notification service persistence

This commit is contained in:
timothycarambat
2025-07-11 14:34:41 -07:00
parent 0ba2bfad2a
commit 170432cfe5
8 changed files with 152 additions and 99 deletions

View File

@@ -6,7 +6,7 @@ concurrency:
on:
push:
branches: ['4034-version-control'] # put your current branch to create a build. Core team only.
branches: ['web-push-notifications-service'] # put your current branch to create a build. Core team only.
paths-ignore:
- '**.md'
- 'cloud-deployments/*'

View File

@@ -8,14 +8,13 @@ function parseEventData(event) {
}
self.addEventListener('push', function (event) {
const data = parseEventData(event);
if (!data) return;
const payload = parseEventData(event);
if (!payload) return;
// options: https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration/showNotification#options
self.registration.showNotification(data.title || 'AnythingLLM', {
body: data.message,
self.registration.showNotification(payload.title || 'AnythingLLM', {
...payload,
icon: '/favicon.png',
data: { ...data }
});
});

View File

@@ -2,11 +2,17 @@ import { useEffect } from "react";
import { API_BASE } from "@/utils/constants";
import { baseHeaders } from "@/utils/request";
const PUSH_PUBKEY_URL = `${API_BASE}/utils/web-push/pubkey`;
const PUSH_USER_SUBSCRIBE_URL = `${API_BASE}/utils/web-push/subscribe`;
const PUSH_PUBKEY_URL = `${API_BASE}/web-push/pubkey`;
const PUSH_USER_SUBSCRIBE_URL = `${API_BASE}/web-push/subscribe`;
// If you update the service worker, increment this version or else
// the service worker will not be updated with new changes -
// Its version ID is independent of the app version to prevent reloading
// or cache busting when not needed.
const SW_VERSION = "1.0.0";
function log(message, ...args) {
if (typeof message === 'object') message = JSON.stringify(message, null, 2);
if (typeof message === "object") message = JSON.stringify(message, null, 2);
console.log(`[useWebPushNotifications] ${message}`, ...args);
}
@@ -17,19 +23,14 @@ function log(message, ...args) {
*/
export async function subscribeToPushNotifications() {
try {
if (sessionStorage.getItem("push-notifications-subscribed")) {
log("Push notifications already subscribed for this session");
return;
}
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
log("Push notifications not supported");
return;
}
// Check current permission status
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
if (permission !== "granted") {
log("Notification permission not granted");
return;
}
@@ -44,42 +45,59 @@ export async function subscribeToPushNotifications() {
if (!publicKey) return log("No public key found or generated");
const swReg = await navigator.serviceWorker.register("/service-workers/push-notifications.js");
const swReg = await navigator.serviceWorker.register(
`/service-workers/push-notifications.js?v=${SW_VERSION}`
);
log({
installing: swReg.installing,
waiting: swReg.waiting,
active: swReg.active,
})
// Check for updates
swReg.addEventListener("updatefound", () => {
const newWorker = swReg.installing;
log("Service worker update found");
newWorker.addEventListener("statechange", () => {
if (
newWorker.state === "installed" &&
navigator.serviceWorker.controller
) {
// New service worker is installed and ready
log("New service worker installed, ready to activate");
// Optionally show a notification to the user
if (confirm("A new version is available. Reload to update?")) {
window.location.reload();
}
}
});
});
// Handle service worker updates
navigator.serviceWorker.addEventListener("controllerchange", () => {
log("Service worker controller changed");
});
if (swReg.installing) {
await new Promise((resolve) => {
swReg.installing.addEventListener('statechange', () => {
if (swReg.installing?.state === 'activated') resolve();
swReg.installing.addEventListener("statechange", () => {
if (swReg.installing?.state === "activated") resolve();
});
});
} else if (swReg.waiting) {
await new Promise((resolve) => {
swReg.waiting.addEventListener('statechange', () => {
if (swReg.waiting?.state === 'activated') resolve();
swReg.waiting.addEventListener("statechange", () => {
if (swReg.waiting?.state === "activated") resolve();
});
});
}
log({ isactive: swReg.active, hasPushManager: !!swReg.pushManager });
const subscription = await swReg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
});
await fetch(PUSH_USER_SUBSCRIBE_URL, {
method: "POST",
body: JSON.stringify(subscription),
headers: baseHeaders(),
});
sessionStorage.setItem("push-notifications-subscribed", "true");
} catch (error) {
log("Error subscribing to push notifications", error);
}
@@ -102,4 +120,4 @@ function urlBase64ToUint8Array(base64String) {
.replace(/_/g, "/");
const rawData = atob(base64);
return new Uint8Array([...rawData].map((char) => char.charCodeAt(0)));
}
}

View File

@@ -1,7 +1,4 @@
const { SystemSettings } = require("../models/systemSettings");
const { reqBody } = require("../utils/http");
const { validatedRequest } = require("../utils/middleware/validatedRequest");
const { pushNotificationService } = require("../utils/PushNotifications");
function utilEndpoints(app) {
if (!app) return;
@@ -24,17 +21,6 @@ function utilEndpoints(app) {
response.sendStatus(500).end();
}
});
app.post("/utils/web-push/subscribe", [validatedRequest], async (request, response) => {
const subscription = reqBody(request);
await pushNotificationService.registerSubscription(response.locals.user, subscription);
response.status(201).json({});
});
app.get("/utils/web-push/pubkey", [validatedRequest], (request, response) => {
const publicKey = pushNotificationService.publicVapidKey;
res.status(200).json({ publicKey });
});
}
function getGitVersion() {

View File

@@ -0,0 +1,27 @@
const { reqBody } = require("../utils/http");
const { validatedRequest } = require("../utils/middleware/validatedRequest");
const { pushNotificationService } = require("../utils/PushNotifications");
function webPushEndpoints(app) {
if (!app) return;
app.post(
"/web-push/subscribe",
[validatedRequest],
async (request, response) => {
const subscription = reqBody(request);
await pushNotificationService.registerSubscription(
response.locals.user,
subscription
);
response.status(201).json({});
}
);
app.get("/web-push/pubkey", [validatedRequest], (_request, response) => {
const publicKey = pushNotificationService.publicVapidKey;
response.status(200).json({ publicKey });
});
}
module.exports = { webPushEndpoints };

View File

@@ -28,6 +28,7 @@ const { browserExtensionEndpoints } = require("./endpoints/browserExtension");
const { communityHubEndpoints } = require("./endpoints/communityHub");
const { agentFlowEndpoints } = require("./endpoints/agentFlows");
const { mcpServersEndpoints } = require("./endpoints/mcpServers");
const { webPushEndpoints } = require("./endpoints/webPush");
const app = express();
const apiRouter = express.Router();
const FILE_LIMIT = "3GB";
@@ -65,6 +66,7 @@ developerEndpoints(app, apiRouter);
communityHubEndpoints(apiRouter);
agentFlowEndpoints(apiRouter);
mcpServersEndpoints(apiRouter);
webPushEndpoints(apiRouter);
// Externally facing embedder endpoints
embeddedEndpoints(apiRouter);

View File

@@ -1,7 +1,23 @@
const webpush = require("web-push");
const fs = require("fs");
const path = require("path");
const { safeJsonParse } = require("../../utils/http");
const { User } = require("../../models/user");
const { SystemSettings } = require("../../models/systemSettings");
const { safeJsonParse } = require("../http");
/**
* For more options, see:
* https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration/showNotification#options
* @typedef {Object} PushNotificationPayload
* @property {string} title - The title of the notification.
* @property {string} body - The message of the notification.
* @property {Object} data - Unstructured data for the notification. Use this for anything non-standard.
* @property {string} [data.onClickUrl] - The URL to open when the notification is clicked. Note: Can be relative or absolute.
* @property {Object[]} actions - The actions for the notification.
* @property {string} [actions[].action] - The action to perform when the notification is clicked. Handled in the service worker.
* @property {string} [actions[].title] - The title of the action to show in the Options dropdown
* @property {string} image - A string containing the URL of an image to be displayed in the notification.
*/
class PushNotifications {
static mailTo = "anythingllm@localhost";
@@ -93,24 +109,28 @@ class PushNotifications {
* @returns {Promise<void>}
*/
async loadSubscriptions() {
const { User } = require("../../models/user");
const { SystemSettings } = require("../../models/systemSettings");
const isMultiUserMode = await SystemSettings.isMultiUserMode();
if (isMultiUserMode) {
const users = await User._where({ web_push_subscription_config: { not: null } });
const users = await User._where({
web_push_subscription_config: { not: null },
});
for (const user of users) {
const subscription = safeJsonParse(user.web_push_subscription_config, null);
const subscription = safeJsonParse(
user.web_push_subscription_config,
null
);
if (subscription) this.#subscriptions.set(user.id, subscription);
}
this.#log(`Loaded ${this.#subscriptions.size} existing subscriptions.`);
return;
}
this.#log('Loading single user mode subscriptions...');
this.#log("Loading single user mode subscriptions...");
if (!fs.existsSync(this.primarySubscriptionPath)) return;
const subscription = JSON.parse(fs.readFileSync(this.primarySubscriptionPath, "utf8"));
if (subscription) this.#subscriptions.set('primary', subscription);
const subscription = JSON.parse(
fs.readFileSync(this.primarySubscriptionPath, "utf8")
);
if (subscription) this.#subscriptions.set("primary", subscription);
this.#log(`Loaded primary user's existing subscription.`);
}
@@ -118,73 +138,79 @@ class PushNotifications {
* Register a new subscription for a user.
* In single user mode, the userId is mapped to "primary"
* In multi user mode, the userId is the user's id in the database
*
*
* @param {Object|null} user - The user to register the subscription for.
* @param {Object} subscription - The subscription to register.
* @returns {Promise<PushNotifications>}
*/
async registerSubscription(user = null, subscription) {
let userId = user?.id || 'primary';
let userId = user?.id || "primary";
this.#subscriptions.set(userId, subscription);
// If this was a real user, write the subscriptions to the database
// If this was a real user, write the subscription to the database
if (!!user) {
const { User } = require("../../models/user");
await User._update(user.id, { web_push_subscription_config: JSON.stringify(subscription) });
this.#log(`Registered subscription for user - ${user.id}`);
await User._update(user.id, {
web_push_subscription_config: JSON.stringify(subscription),
});
this.#log(`Registered or updated subscription for user - ${user.id}`);
} else {
if (!fs.existsSync(this.primarySubscriptionPath)) fs.mkdirSync(this.primarySubscriptionPath, { recursive: true });
fs.writeFileSync(this.primarySubscriptionPath, JSON.stringify(subscription, null, 2));
this.#log(`Registered primary user's subscription.`);
if (!fs.existsSync(this.storagePath))
fs.mkdirSync(this.storagePath, { recursive: true });
fs.writeFileSync(
this.primarySubscriptionPath,
JSON.stringify(subscription, null, 2)
);
this.#log(`Registered or updated primary user's subscription.`);
}
return this;
}
/**
* Send a push notification to all subscribed clients.
* @param {"all"|number} to - The subscription to send the notification to. "all" sends to all subscriptions, a number sends subscription to specific user
* @param {Object} payload - The payload to send to the clients.
* @param {Object} options - The options for the notification.
* @param {"all"|"primary"|number} [options.to] - The subscription to send the notification to. "all" sends to all subscriptions, "primary" sends to the primary user (single user mode only), a number sends subscription to specific user
* @param {PushNotificationPayload} [options.payload] - The payload to send to the clients.
* @returns {void}
*/
sendNotification(to = null, payload = {}) {
// this.pushService.sendNotification(to || this.subscriptions, payload);
sendNotification({ to = "primary", payload = {} } = {}) {
if (this.#subscriptions.size === 0)
return this.#log(".sendNotification() - No subscriptions found");
if (!this.#subscriptions.has(to))
return this.#log(
`.sendNotification() - Subscription for user ${to} not found`
);
this.#log(`.sendNotification() - Sending notification to user ${to}`);
this.pushService.sendNotification(
this.#subscriptions.get(to),
JSON.stringify(payload)
);
}
/**
* Setup the push notification service.
* This will generate new VAPID keys if they don't exist and save them to the storage path.
* It will also load the subscriptions from the database or the primary-subscription.json file.
* @returns {Promise<void>}
*/
static async setupPushNotificationService() {
const instance = PushNotifications.instance;
const existingVapidKeys = instance.existingVapidKeys;
if (existingVapidKeys.publicKey && existingVapidKeys.privateKey) {
instance.pushService;
return;
}
instance.#log("Generating new VAPID keys...");
const vapidKeys = webpush.generateVAPIDKeys();
instance.#vapidKeys.publicKey = vapidKeys.publicKey;
instance.#vapidKeys.privateKey = vapidKeys.privateKey;
instance.#log(`New VAPID keys generated!`);
if (!fs.existsSync(instance.storagePath)) fs.mkdirSync(instance.storagePath, { recursive: true });
fs.writeFileSync(
path.resolve(instance.storagePath, `vapid-keys.json`),
JSON.stringify(vapidKeys, null, 2)
);
const isMultiUserMode = await SystemSettings.isMultiUserMode();
if (isMultiUserMode) {
const users = await User.where({ web_push_subscription_config: { not: null } });
// for (const user of users) {
// const subscription = JSON.parse(user.web_push_subscription_config);
// if (subscription) instance.registerSubscription(user, subscription);
// }
} else {
if (!existingVapidKeys.publicKey || !existingVapidKeys.privateKey) {
instance.#log("Generating new VAPID keys...");
const vapidKeys = webpush.generateVAPIDKeys();
instance.#vapidKeys.publicKey = vapidKeys.publicKey;
instance.#vapidKeys.privateKey = vapidKeys.privateKey;
instance.#log(`New VAPID keys generated!`);
if (!fs.existsSync(instance.storagePath))
fs.mkdirSync(instance.storagePath, { recursive: true });
fs.writeFileSync(
path.resolve(instance.storagePath, `vapid-keys.json`),
JSON.stringify(vapidKeys, null, 2)
);
}
await instance.loadSubscriptions();
instance.pushService;
return;
}

View File

@@ -64,11 +64,6 @@ function bootHTTP(app, port = 3001) {
new BackgroundService().boot();
await PushNotifications.setupPushNotificationService();
console.log(`Primary server in HTTP mode listening on port ${port}`);
// setTimeout(() => {
// PushNotifications.instance.pushService.sendNotification(
// )
// }, 1000);
})
.on("error", catchSigTerms);