PageSourceSearch

https://vanceai.com/webpush/sw.js

js vanceai.com collected 2026-10-02 04:24:58 UTC 4,879 bytes, 126 lines download raw bytes

1// Web Push Service Worker:接收推送、展示通知、上报生命周期 · vanceai-aio
2//
3// 行为等价于旧站 web-next/public/webpush/sw.js(原文件是单行压缩代码,此处重写为可读版本)。
4//
5// 🔴 本文件与 public/sw.js 必须逐字节相同 —— tests/unit/config/webpush.test.ts 有不变量守着。
6//    两个路径都要存在:/webpush/sw.js 是当前注册路径(scope /webpush/),
7//    /sw.js 是更早期版本留下的存量注册(scope /)。任一路径返回非 2xx,
8//    浏览器 update check 失败后会**保留旧 SW 且无法更新**(这正是 2026-07~08 那次失控的成因)。
9//
10// 🔴 API_ORIGIN 必须与 src/config/webpush.ts 的 WEB_PUSH_API_ORIGIN 一致(同上测试守着)。
11//    静态文件读不到构建期 env,只能硬编码。
12//
13// 🔴 本文件的 fetch 受站点 CSP connect-src 约束 —— next.config.ts 的 headers() 用
14//    source '/(.*)' 覆盖所有路径,包含本脚本自身;漏放行会让上报被静默拦掉。
15
16const DEFAULT_API_ORIGIN = 'https://aiportrait.online'
17
18/**
19 * 上报通知生命周期事件(received / clicked / closed)。
20 * 失败只吞掉不重试:遥测不值得影响通知本身的展示。
21 *
22 * 🔴 **必须恒返回 Promise**:调用方要把它交给 `event.waitUntil()`,
23 * 而 `waitUntil(undefined)` 会抛 TypeError,反而把整个事件处理搞挂。
24 */
25function report(apiOrigin, payload) {
26  try {
27    return fetch(apiOrigin + '/index.php/sub/listen', {
28      method: 'POST',
29      body: JSON.stringify(payload),
30      headers: new Headers({ 'Content-Type': 'application/json' }),
31    }).catch(() => undefined)
32  } catch {
33    return Promise.resolve()
34  }
35}
36
37/** 仅允许 http(s) 跳转,挡住 javascript: / data: 这类协议。 */
38function isSafeUrl(url) {
39  return typeof url === 'string' && /^https?:\/\//.test(url)
40}
41
42self.addEventListener('install', () => {
43  // 跳过 waiting:让新版 SW 立即接管,否则更新要等所有旧标签页关闭
44  self.skipWaiting()
45})
46
47self.addEventListener('activate', (event) => {
48  // 立即接管已打开的页面(否则首次安装后要等下次导航才生效)
49  event.waitUntil(self.clients.claim())
50})
51
52self.addEventListener('push', (event) => {
53  let data
54  try {
55    data = JSON.parse(event.data.text())
56  } catch {
57    return
58  }
59  if (!data || !data.message_id || !data.title || !data.alert) return
60
61  // 推送载荷可覆盖上报端点(推送平台的多租户/灰度机制,1:1 旧站行为)
62  const apiOrigin = isSafeUrl(data.sw_api) ? data.sw_api : DEFAULT_API_ORIGIN
63
64  const reported = report(apiOrigin, {
65    type: 'received',
66    user_id: data.user_id,
67    message_id: data.message_id,
68    app_id: data.app_id,
69  })
70
71  const options = {
72    body: data.alert,
73    data: {
74      uid: data.user_id,
75      mid: data.message_id,
76      aid: data.app_id,
77      url: data.custom && data.custom.u,
78      apiOrigin,
79      actions: data.o,
80    },
81  }
82  if (data.icon) options.icon = data.icon
83  if (data.image) options.image = data.image
84  if (data.tag) options.tag = data.tag
85  if (data.badge) options.badge = data.badge
86  if (data.require_interaction) options.requireInteraction = true
87  if (Array.isArray(data.o)) {
88    const actions = data.o.filter((a) => a && a.i && a.n).map((a) => ({ action: a.i, title: a.n, icon: a.p }))
89    if (actions.length) options.actions = actions
90  }
91
92  // waitUntil 必须同时括住上报:事件处理函数一返回浏览器就可能终止 SW,
93  // 裸 fetch 会被连带丢弃 —— 遥测静默丢数,且只在真机低电量/后台时才复现。
94  event.waitUntil(Promise.all([reported, self.registration.showNotification(data.title, options)]))
95})
96
97self.addEventListener('notificationclick', (event) => {
98  event.notification.close()
99  const info = event.notification.data || {}
100  const apiOrigin = isSafeUrl(info.apiOrigin) ? info.apiOrigin : DEFAULT_API_ORIGIN
101
102  const reported = report(apiOrigin, {
103    type: 'clicked',
104    user_id: info.uid,
105    message_id: info.mid,
106    app_id: info.aid,
107  })
108
109  let target = info.url
110  // 点了某个 action 按钮时,用该 action 自带的 URL 覆盖默认跳转
111  if (event.action && Array.isArray(info.actions)) {
112    const hit = info.actions.find((a) => a && a.i === event.action)
113    if (hit && hit.u === 'close') {
114      event.waitUntil(reported)
115      return
116    }
117    if (hit && isSafeUrl(hit.u)) target = hit.u
118  }
119  event.waitUntil(isSafeUrl(target) ? Promise.all([reported, self.clients.openWindow(target)]) : reported)
120})
121
122self.addEventListener('notificationclose', (event) => {
123  const info = event.notification.data || {}
124  const apiOrigin = isSafeUrl(info.apiOrigin) ? info.apiOrigin : DEFAULT_API_ORIGIN
125  event.waitUntil(report(apiOrigin, { type: 'closed', user_id: info.uid, message_id: info.mid, app_id: info.aid }))
126})

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.