PushAPI ServiceWorker Node Service 通知
一、认识
在一个完备的 Web
推送系统中,服务端通过 web-push
模块生成全局性的 VAPID
密钥对并调用setVapidDetails
配置包含 JWT
签名及必要 HTTP
头的推送请求,自动实现基于 ECDH
、HKDF
和AES-GCM
的 payload
加密,从而将安全、符合 Web
推送协议的通知发送到所有订阅者,而客户端则先通过请求通知权限和注册 Service Worker
获取激活状态,再利用 PushManager
结合 Uint8Array
格式的VAPID
公钥生成订阅对象,并在推送事件中通过 waitUntil
延长生命周期、调用 showNotification
展示通知和 postMessage
实现跨上下文通信,共同构建了一套安全、透明且高效的实时消息传递体系。
web-push
认识: web-push
模块是实现服务端 Web
推送的关键工具,它将复杂的推送协议细节、加密流程以及身份验证机制封装到简单易用的 API
中。通过自动加密消息、内置 VAPID
支持和兼容旧版推送服务,web-push
帮助开发者构建安全、可靠的推送系统,使得跨浏览器的消息推送变得更加便捷和高效。1. web-push
简化消息发送, 开发者只需调用简单的 API
,传入订阅信息和消息内容,无需手动构造复杂的 HTTP
请求或处理加密细节; 2. 自动加密, 模块内部自动完成消息 payload
的加密流程,确保所有数据在传输过程中都是安全的; 3. 内置 VAPID
支持, 只需提供 VAPID
密钥对,web-push
就能生成对应的 JWT
,并将服务器身份信息嵌入到推送请求中,简化了身份验证过程; 4. 兼容性处理, 对于依赖于 GCM
(现 FCM
)的浏览器,web-push
提供了向后兼容的支持,确保推送消息在不同浏览器环境中都能正确传递。
使得服务端向浏览器推送消息通知工作流:
一、VAPID
签名:
-
服务端: 服务启动后, 调用
webPush.generateVAPIDKeys()
来生成全局的VAPID.publicKey
和VAPID.privateKey
。调用webPush.setVapidDetails
来设置全局的VAPID
详细信息, 包括:Subject
, 通常是一个mailto:
邮箱地址或URL
,用于标服务器或应用;VAPID
公钥, 用于客户端加密订阅数据,并在推送请求中附带身份信息;VAPID
私钥, 用于对推送请求进行签名,证明推送请求的合法性。所有用户的推送通知都会使用这同一组VAPID
证书进行身份验证和加密处理。并将生成的VAPID.publicKey
下发到客户端。 -
客户端: 接收到
VAPID.publicKey
, 格式为base64
, 将base64
格式转化为Unit8Array
格式, 以备后续使用。
二、客户端工作流:
-
订阅通知: 客户端通过
Notification.requestPermission
向用户请求通知权限, 用户同意接收通知后, 浏览器通过Service Worker
与PushManager
生成订阅对象,并将其发送到服务端。首先, 客户端需要注册Service Worker
。然后, 客户端通过navigator.serviceWorker
获取到ServiceWorkerContainer
, 用于提供ServiceWorker
的注册、移除、升级和通信的访问。ServiceWorkerContainer
有一个ready
属性, 提供了一种将代码执行延迟到Service Worker
处于活动状态的方法。它返回一个永远不会拒绝的Promise
,并且无限期地等待,直到与当前页面关联的ServiceWorkerRegistration
具有活动的worker
。满足该条件后,它将使用ServiceWorkerRegistration
解析, 最终会返回一个ServiceWorkerRegistration
实例对象。ServiceWorkerRegistration
有一个PushManager
对象实例, 支持订阅,获取活动订阅和访问推送权限状态。PushManager
调用subscribe
并传入之前存储的VAPID
公钥, 来订阅通知, 并且将返回的PushSubscription
数据发送给服务端。 -
接收推送: 当服务端发送推送消息时,浏览器会唤醒已注册的
Service Worker
,处理推送事件并显示通知或传递消息给页面。Service Worker
中注册了push
事件和notificationclick
。当服务端发送推送消息后,push
事件会收到该消息, 在event.waitUntil
回调中, 调用self.registration.showNotification
来显示通知弹窗, 并且这时候可以通过clients.matchAll
获取我们的客户端, 通过postMessage
发送一条消息, 当我们客户端的主线程收到消息之后, 就可以做刷新之类的操作了。用户点击通知弹窗会触发notificationclick
, 这时候可以做相应的处理, 比如打开页面。event.waitUtil
: 接受一个Promise
, 通知浏览器, 在这个Promise
解决之前,请不要将当前Service Worker
实例终止。由于Service Worker
是事件驱动的,waitUntil
通过延长事件的生命周期来保护异步操作,确保诸如资源缓存、消息处理和后台同步等任务能够完整执行。
三、服务端工作流:
-
接收订阅信息: 服务端收到客户端发送的订阅对象,并存储用于后续推送。
-
构造推送请求: 使用
web-push
模块构造HTTP
请求,按照Web
推送协议的要求:-
消息加密: 如果需要传输
payload
,web-push
会自动使用ECDH
密钥交换、HKDF
派生以及AES-GCM
加密算法对数据进行加密,确保消息在传输过程中不被窃取或篡改。 -
VAPID
签名: 模块利用服务端提供的VAPID
密钥对生成JWT
,并将其附加到请求中,证明服务器的合法性。 -
构造
HTTP
请求: 包括必要的头信息(例如TTL
、Content-Encoding
、Authorization
等),确保推送服务器能够正确解析和转发消息。
-
-
发送消息: 推送请求被发送到用户订阅中指定的
endpoint
,推送服务(如FCM
、Mozilla
的推送服务)将通过webPush.sendNotification
消息传递到目标浏览器。
二、服务端
2.1 vapid.js
const webPush = require('web-push');
const vapidKeys = webPush.generateVAPIDKeys();
console.log(vapidKeys);
2.2 server.js
const Koa = require("koa");
const cors = require("@koa/cors");
const webPush = require("web-push");
const Router = require("@koa/router");
const bodyParser = require("koa-bodyparser");
const app = new Koa();
const router = new Router();
app.use(
cors({
origin: "*",
})
);
const vapidKeys = {
publicKey:
"BPkGGzVDFEOVvxOF-h0vRJQTrhYd4ZxwUN_of6t3kjUpAnaIPXqHjtx8c0i53fkdd-rn8dK3lwABntRW1sV9zzo",
privateKey: "_4HSd2L6F2xlFkx9XCgdl7RliyybYcn7DiCrlWoXHUE",
};
webPush.setVapidDetails(
"mailto:zwq18235130928@gmail.com",
vapidKeys.publicKey,
vapidKeys.privateKey
);
let subscriptions = [];
router.post("/subscribe", (ctx) => {
const subscription = ctx.request.body;
subscriptions.push(subscription);
ctx.status = 201;
ctx.body = {};
});
router.post("/unsubscribe", (ctx) => {
const subscription = ctx.request.body;
subscriptions = subscriptions.filter(
(sub) => sub.endpoint !== subscription.endpoint
);
ctx.status = 200;
ctx.body = {};
});
router.post("/sendNotification", async (ctx) => {
const notificationPayload = {
icon: "",
url: "https://baidu.com",
title: "测试 Web 消息推送",
body: "这是一条通过 Web Push 发送的消息。",
};
const promises = subscriptions.map((subscription) => {
return webPush
.sendNotification(subscription, JSON.stringify(notificationPayload))
.catch((err) => console.error("发送通知错误:", err));
});
await Promise.all(promises);
ctx.status = 200;
});
app.use(bodyParser());
app.use(router.routes());
app.use(router.allowedMethods());
app.listen(3000, () => {
console.log("Koa 服务器已启动,监听 3000 端口");
});
三、客户端
3.1 client.js
/**
* @description:
*
* 1. navigator.serviceWorker 接口的 serviceWorker 只读属性返回关联文档的 ServiceWorkerContainer 对象,用于提供 ServiceWorker 的注册、移除、升级和通信的访问。
*/
const serviceUrl = "http://localhost:3000";
const vapidPublicKey =
"BPkGGzVDFEOVvxOF-h0vRJQTrhYd4ZxwUN_of6t3kjUpAnaIPXqHjtx8c0i53fkdd-rn8dK3lwABntRW1sV9zzo";
function urlBase64ToUint8Array(base64String) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, "+")
.replace(/_/g, "/");
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
async function subscribe() {
const registration = await navigator.serviceWorker.ready;
let subscription = await registration.pushManager.getSubscription();
if (!subscription) {
const applicationServerKey = urlBase64ToUint8Array(vapidPublicKey);
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: applicationServerKey,
});
}
fetch(serviceUrl + "/subscribe", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(subscription),
})
.then((res) => {
console.log("订阅成功!!!", res);
})
.catch((error) => {
console.log("订阅失败", error);
});
}
async function unsubscribe() {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (!subscription) {
console.log("暂无订阅");
}
await subscription.unsubscribe();
fetch(serviceUrl + "/unsubscribe", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(subscription),
})
.then((res) => {
console.log("取消订阅成功", res);
})
.catch((error) => {
console.log("取消订阅失败", error);
});
}
async function initServiceWorker() {
if (!("showNotification" in ServiceWorkerRegistration.prototype)) {
console.warn("Notifications aren't supported.");
return;
}
if (!"serviceWorker" in navigator) {
console.log("不支持 Service Worker");
}
if (!("PushManager" in window)) {
console.warn("不支持 PushManager");
return;
}
const permission = await Notification.requestPermission();
if (permission !== "granted") {
console.log("未授予通知权限");
}
await navigator.serviceWorker.register("./serviceWorker.js");
console.log("Service Worker 注册成功");
subscribe();
}
function run() {
initServiceWorker();
navigator.serviceWorker.addEventListener("message", (event) => {
if (event.data && event.data.type === "servicer_worker_receive_message") {
const pushData = event.data.payload;
console.log("收到推送消息:", pushData);
// fetch('/api/getMoreData').then(...);
}
});
document
.getElementById("subscribeButton")
?.addEventListener?.("click", subscribe);
document
.getElementById("unsubscribeButton")
?.addEventListener?.("click", unsubscribe);
}
run();
3.2 client.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<button id="subscribeButton">订阅消息推送</button>
<button id="unsubscribeButton">取消消息推送</button>
<script src="./client.js"></script>
</body>
</html>
3.3 serviceWorker.js
async function sendMessageToClient(data) {
const allClients = await clients.matchAll({
includeUncontrolled: true,
});
if (allClients && allClients.length > 0) {
allClients.forEach((client) => {
client.postMessage({
payload: data,
type: "servicer_worker_receive_message",
});
});
}
}
async function showClientNotification(data) {
const title = data.title;
const options = {
body: data.body,
icon: data.icon,
data: {
url: data.url,
},
};
await self.registration.showNotification(title, options);
}
async function pushWaitUntilCallback(_data) {
const data = await _data.json();
await sendMessageToClient(data);
await showClientNotification(data);
}
self.addEventListener("push", function (event) {
if (event.data) {
event.waitUntil(pushWaitUntilCallback(event.data));
}
});
self.addEventListener("notificationclick", function (event) {
event.notification.close();
event.waitUntil(clients.openWindow(event.notification.data.url));
});
四、测试效果
一、执行 vapid.js
: 生成 vapidKeys.publicKey
和 vapidKeys.privateKey
node vapid.js
二、替换 service.js
中的密钥信息: 将生成的 vapidKeys.publicKey
和 vapidKeys.privateKey
替换到 webPush.setVapidDetails
里去。
三、启动 Node Service
node service.js
四、启动前端 index.html
页面服务
五、通过 curl
请求 Node
服务: 此时, 浏览器会收到通知的。
curl -X POST http://localhost:3000/sendNotification