通知中的概念
推送订阅的过期与刷新
发布于
一个推送订阅可能带有过期时间,到时浏览器会把它停用;浏览器或推送服务也可能在任意时刻用一个新订阅替换它。规范把这两种结果都导向同一处:Service Worker 中的一个 pushsubscriptionchange 事件,携带不应再使用的那个订阅以及替换它的那个订阅。
BCD api.PushSubscription.expirationTime 记录 Chrome 60、Firefox 96、Safari 16 与 iOS Safari 16.4 起支持,并镜像到该记录列出的 Chromium 系浏览器;Android WebView 无支持。MDN 把该属性标记为 Baseline“Widely available”,自 2023 年 3 月起在各浏览器中可用,且限于安全上下文。
事件的处境更弱。MDN 把 pushsubscriptionchange 标记为“Limited availability”,并写明它“is not Baseline because it does not work in some of the most widely-used browsers”。BCD api.ServiceWorkerGlobalScope.pushsubscriptionchange_event 记录它自 Chrome 138 起支持;自 Firefox 44 与 Firefox for Android 48 起为部分实现;自 Safari 16 起支持,并附注通知在 macOS Ventura 及更高版本上受支持。它记录 iOS Safari 无支持。事件的两个成员在 api/PushSubscriptionChangeEvent.json(github.com)中单独记录,其中 oldSubscription 与 newSubscription 自 Chrome 138、Firefox 137、Safari 16 与 iOS Safari 16.4 起支持。
Push API(w3.org)写明推送订阅“MAY have an associated subscription expiration time”,并且在该时间被设置时,它是“the time, in milliseconds since 00:00:00 UTC on 1 January 1970, at which the subscription will be deactivated”。同一段还要求用户代理“SHOULD attempt to refresh the push subscription before the subscription expires”,所以过期是浏览器被要求提前处理的截止时间,而不是应用需要自行排程的事件。
PushSubscription.expirationTime 暴露的就是这个值。规范把它声明为 readonly attribute EpochTimeStamp?,其取值器在存在过期时间时返回该时间,否则返回 null;toJSON() 会把它复制进 PushSubscriptionJSON 字典,该字典的 expirationTime 成员默认为 null。MDN 的 expirationTime 页面(developer.mozilla.org)把同一个值描述为一个 DOMHighResTimeStamp 或 null。
刷新与过期是两件事,且并不绑定。规范允许用户代理或推送服务“at any time, for example because it has reached a certain age”刷新任何关联 Service Worker 注册非 null 的订阅,并要求在用户代理因任何原因必须更换订阅密钥、且该注册非 null 时执行刷新。一次刷新会用原来的 PushSubscriptionOptions 创建新订阅、保留原来的 scope,并且必须产生与原订阅不同的密钥对。随后用户代理触发 pushsubscriptionchange,以最初的订阅作为 oldSubscription、新的订阅作为 newSubscription。
规范为这次交接留出了宽限期:用户代理“MAY continue to accept messages for an old push subscription for a brief time after a refresh”,而一旦已经为刷新后的订阅收到消息,旧订阅就必须被停用。停用会删除用户代理与推送服务两边存储的副本、停止投递后续消息,并禁止把该端点重新用于新的订阅。
newSubscription 为 null 是该事件的另一种形态。当订阅已无法再使用(例如因为它已过期)而用户代理又无法刷新它时,规范要求触发该事件,以正在停用的订阅作为 oldSubscription、null 作为 newSubscription。当某项权限被撤销时,用户代理可以以同样的方式触发该事件,并且必须停用受影响的订阅。PushSubscriptionChangeEventInit 的定义给出了该成员在这些路径上的通用含义:newSubscription“will be null when no new push subscription could be established, for example because the web application has lost express permission”。对应的另一面也有定义:oldSubscription“will be null when the user agent is not able to provide the full set of details, for example because of partial database corruption”。
PushSubscriptionChangeEvent 被声明为 [Exposed=ServiceWorker, SecureContext] 并继承 ExtendableEvent,所以处理器可以使用 waitUntil();ServiceWorkerGlobalScope 还暴露了一个 onpushsubscriptionchange 处理器属性。MDN 补充说该事件不可取消,也不冒泡。
处理器运行在 Service Worker 中,那里没有页面可供汇报,所以每个示例决定的是持久化什么与重试什么,而不是显示什么。
重新订阅并上报新端点
Section titled “重新订阅并上报新端点”把旧订阅的 options 原样传回 subscribe():这正是规范在浏览器发起刷新时复用的那个字典,它携带着原来的 applicationServerKey。把整条链条包进 waitUntil(),这样 worker 不会在中途被终止。
self.addEventListener('pushsubscriptionchange', (event) => { event.waitUntil( (async () => { const options = event.oldSubscription?.options ?? { userVisibleOnly: true }; const next = await self.registration.pushManager.subscribe(options); await fetch('/push/rotate', { method: 'POST', headers: { 'Content-type': 'application/json' }, body: JSON.stringify({ old: event.oldSubscription?.endpoint ?? null, new: next.toJSON(), }), }); })(), );});这里用 toJSON() 作为载荷很方便,因为规范定义它携带 endpoint、expirationTime 以及 base64url 形式的 p256dh 与 auth 密钥,而这正是应用服务器加密下一条消息所需要的。
处理缺少这两个成员的浏览器
Section titled “处理缺少这两个成员的浏览器”BCD 记录 Firefox 44 与 Firefox for Android 48 为部分实现,其附注写明该事件“does not have the oldSubscription and newSubscription properties”,而这两个成员自 Firefox 137 起具备。在那些版本上读取 event.oldSubscription 的处理器没有可比对的对象,所以请检测该成员,并回退到注册对象当前的订阅。
self.addEventListener('pushsubscriptionchange', (event) => { event.waitUntil( (async () => { if (!('oldSubscription' in event)) { // Firefox 137 之前:事件触发时两个成员都没有。 const current = await self.registration.pushManager.getSubscription(); await fetch('/push/resync', { method: 'POST', body: JSON.stringify({ current: current ? current.toJSON() : null }), }); return; } await rotate(event.oldSubscription, event.newSubscription); })(), );});/push/resync 这条路由必须用旧端点之外的东西去匹配已有记录,因为处理器无法指出它。按一个稳定的安装级标识符而非仅按端点来给推送记录建索引,正是让这条路由成为可能的前提。
检测该事件,并在它不存在时对账
Section titled “检测该事件,并在它不存在时对账”BCD 记录 iOS Safari 不支持 pushsubscriptionchange。在那个环境里为它注册的处理器收不到事件,所以上面的刷新与停用路径必须用别的方式覆盖。ServiceWorkerGlobalScope 暴露了一个 onpushsubscriptionchange 处理器属性,这让 'onpushsubscriptionchange' in self 成为 worker 在依赖该事件触发之前可以做的一次检测。检测不通过时,改为把 getSubscription() 与服务端记录做比对:activate 事件给了 worker 一个在它安装的每个版本上执行这件事的时机。
if ('onpushsubscriptionchange' in self) { self.addEventListener('pushsubscriptionchange', (event) => { event.waitUntil(rotate(event.oldSubscription, event.newSubscription)); });} else { // 该 worker 中没有 pushsubscriptionchange:改为在激活时对账。 self.addEventListener('activate', (event) => { event.waitUntil(reconcileSubscription()); });}
async function reconcileSubscription() { const current = await self.registration.pushManager.getSubscription(); await fetch('/push/reconcile', { method: 'POST', headers: { 'Content-type': 'application/json' }, body: JSON.stringify({ installationId: await readInstallationId(), // 记录所依据的那个稳定索引键 current: current ? current.toJSON() : null, // null:没有可投递的目标 }), });}规范就服务端这一侧给出的结论很窄:已停用的订阅不再收到后续消息,它的端点也不会再被用于另一个订阅。规范并未定义应用服务器如何得知某个订阅已被停用,因此 Push API 并不允许把某个特定的投递响应当作这个信号。本页能够担保的是上面那次对账调用,它既覆盖该事件存在的环境,也覆盖它不存在的环境。
在页面上读取过期时间
Section titled “在页面上读取过期时间”expirationTime 在页面与 worker 中都可读,这让客户端可以把截止时间与端点一并发给服务器。请把 null 当作“没有记录截止时间”,这正是订阅没有过期时间时取值器返回的结果。
async function describeSubscription(registration) { const subscription = await registration.pushManager.getSubscription(); if (!subscription) return { subscribed: false }; const { endpoint, expirationTime } = subscription; return { subscribed: true, endpoint, expiresAt: expirationTime === null ? null : new Date(expirationTime).toISOString(), };}记录下截止时间,服务器就可以停止向一个它已知浏览器将要停用的端点发送消息;这一点之所以重要,是因为规范对已停用的订阅停止投递,并禁止重新使用它的端点。
以下都是精确字符串,各自来自所注明的来源。BCD 在 api.ServiceWorkerGlobalScope.pushsubscriptionchange_event 上给 Firefox 的附注写道“The event does not have the oldSubscription and newSubscription properties. See bug 1497429.”(api/ServiceWorkerGlobalScope.json,github.com),而 api.PushSubscriptionChangeEvent 上对应 Firefox 137 的附注写道“Before Firefox 137, The pushsubscriptionchange event is fired, but does not have the oldSubscription and newSubscription properties. See bug 1635524.”
MDN 的事件页面带有一条附注,写道“In earlier drafts of the specification, this event was defined to be sent when a PushSubscription has expired”(pushsubscriptionchange event,developer.mozilla.org)。已发布的规范给出了更宽的范围:该事件“indicates a change in a push subscription that was triggered outside of the application’s control, for example because it has been refreshed, revoked or lost”。
- Push API: subscription refreshes(w3.org)
- ServiceWorkerGlobalScope: pushsubscriptionchange event(developer.mozilla.org)
- Web Push 与 PushManager.subscribe()
- iOS 与 iPadOS 上的 Web Push
- Notification.requestPermission()
规范
| 规范 | 状态 |
|---|---|
| Push API: subscription refreshes | W3C |
| Push API: PushSubscription.expirationTime | W3C |
| Push API: the pushsubscriptionchange event | W3C |