# 推送订阅的过期与刷新

> 订阅过期时间意味着什么、浏览器刷新或丢失订阅时 pushsubscriptionchange 携带什么，以及哪些 Firefox 版本缺少它的两个成员。

一个推送订阅可能带有过期时间，到时浏览器会把它停用；浏览器或推送服务也可能在任意时刻用一个新订阅替换它。规范把这两种结果都导向同一处：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](https://github.com/mdn/browser-compat-data/blob/main/api/PushSubscriptionChangeEvent.json)（github.com）中单独记录，其中 `oldSubscription` 与 `newSubscription` 自 Chrome 138、Firefox 137、Safari 16 与 iOS Safari 16.4 起支持。

## 工作原理

[Push API](https://www.w3.org/TR/push-api/#subscription-refreshes)（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](https://developer.mozilla.org/en-US/docs/Web/API/PushSubscription/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 中，那里没有页面可供汇报，所以每个示例决定的是持久化什么与重试什么，而不是显示什么。

### 重新订阅并上报新端点

把旧订阅的 `options` 原样传回 `subscribe()`：这正是规范在浏览器发起刷新时复用的那个字典，它携带着原来的 `applicationServerKey`。把整条链条包进 `waitUntil()`，这样 worker 不会在中途被终止。

```js
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` 密钥，而这正是应用服务器加密下一条消息所需要的。

### 处理缺少这两个成员的浏览器

BCD 记录 Firefox 44 与 Firefox for Android 48 为部分实现，其附注写明该事件“does not have the `oldSubscription` and `newSubscription` properties”，而这两个成员自 Firefox 137 起具备。在那些版本上读取 `event.oldSubscription` 的处理器没有可比对的对象，所以请检测该成员，并回退到注册对象当前的订阅。

```js
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` 这条路由必须用旧端点之外的东西去匹配已有记录，因为处理器无法指出它。按一个稳定的安装级标识符而非仅按端点来给推送记录建索引，正是让这条路由成为可能的前提。

### 检测该事件，并在它不存在时对账

BCD 记录 iOS Safari 不支持 `pushsubscriptionchange`。在那个环境里为它注册的处理器收不到事件，所以上面的刷新与停用路径必须用别的方式覆盖。`ServiceWorkerGlobalScope` 暴露了一个 `onpushsubscriptionchange` 处理器属性，这让 `'onpushsubscriptionchange' in self` 成为 worker 在依赖该事件触发之前可以做的一次检测。检测不通过时，改为把 `getSubscription()` 与服务端记录做比对：`activate` 事件给了 worker 一个在它安装的每个版本上执行这件事的时机。

```js
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 并不允许把某个特定的投递响应当作这个信号。本页能够担保的是上面那次对账调用，它既覆盖该事件存在的环境，也覆盖它不存在的环境。

### 在页面上读取过期时间

`expirationTime` 在页面与 worker 中都可读，这让客户端可以把截止时间与端点一并发给服务器。请把 `null` 当作“没有记录截止时间”，这正是订阅没有过期时间时取值器返回的结果。

```js
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](https://github.com/mdn/browser-compat-data/blob/main/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](https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/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”。

:::caution
规范在触发该事件处的注释建议，在把新推送订阅的详情发送给应用服务器时考虑“a more reliable synchronization mechanism such as [WEB-BACKGROUND-SYNC]”，因为“the user might be subject to unreliable network conditions that could cause a fetch to fail”。MDN 在其事件页面重复了这一点，指出 `fetch()` 在应用离线时不会工作，并要求作者改用其他方式同步订阅信息，或者处理好交换失败的情况。
:::

## 另请参阅

- [Push API: subscription refreshes](https://www.w3.org/TR/push-api/#subscription-refreshes)（w3.org）
- [ServiceWorkerGlobalScope: pushsubscriptionchange event](https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/pushsubscriptionchange_event)（developer.mozilla.org）
- [Web Push 与 PushManager.subscribe()](/zh/reference/notifications/web-push/)
- [iOS 与 iPadOS 上的 Web Push](/zh/reference/notifications/ios-safari-push/)
- [Notification.requestPermission()](/zh/reference/notifications/permissions/)