mirror of
https://github.com/Mintplex-Labs/anything-llm
synced 2026-04-25 17:15:37 +02:00
Push notification service persistence
This commit is contained in:
2
.github/workflows/dev-build.yaml
vendored
2
.github/workflows/dev-build.yaml
vendored
@@ -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/*'
|
||||
|
||||
@@ -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 }
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
27
server/endpoints/webPush.js
Normal file
27
server/endpoints/webPush.js
Normal 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 };
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user