PageSourceSearch

https://omskinform.ru/sw.js

js omskinform.ru collected 2026-10-02 04:17:45 UTC 18,355 bytes, 370 lines download raw bytes

1// Service worker: пуш-уведомления и офлайн-режим приложения.
2//
3// ВАЖНО ПРО РАДИУС: этот worker регистрируется у КАЖДОГО посетителя сайта
4// (PushNotifications в корневой раскладке), а не только у поставивших
5// приложение. Всё, что здесь написано, меняет способ доставки сайта всей
6// аудитории. И сломанный worker не откатывается выкладкой прежнего образа — он
7// живёт в браузере человека, отменить его может только новый sw.js.
8//
9// Про кэш здесь одна опасность — несвежесть главной, которую чинили в августе.
10// Поэтому страницы берутся ТОЛЬКО стратегией «сначала сеть»: пока сеть есть,
11// человек всегда видит живую версию, а кэш достаётся лишь когда сети нет, и
12// только если копия моложе суток. Кэшируется то, что не устаревает незаметно:
13// файлы сборки (хэш в имени) и картинки новостей (размер и качество в адресе).
14//
15// НЕ перехватываем служебные ответы App Router (`?_rsc=`), `/api/**` и всё
16// прочее. RSC — тот же контент страницы отдельным запросом; закэшировав его, мы
17// вернули бы ту же несвежесть, только незаметнее.
18
19// Поднимать при изменении ПРАВИЛ кэширования, а не при каждом выкате: имена
20// файлов сборки хэшированы, страницы и так проверяются сетью, а рост хранилища
21// удерживают потолки ниже.
22const VERSION = 'v1'
23const CACHE_PREFIX = 'omskinform-'
24// Заглушке нужен свой кэш: лежи она среди файлов сборки, потолок вытеснил бы
25// именно её — она кладётся первой, а вытесняются самые ранние записи.
26// Отдельный кэш под одну служебную запись: страница не может прочитать
27// заголовки навигационного ответа и не может спросить worker'а до того, как
28// загрузится. Поэтому worker оставляет здесь отметку «эта страница отдана из
29// копии», а страница читает её при старте и говорит об этом человеку.
30const STATE_CACHE = `${CACHE_PREFIX}state-${VERSION}`
31const LAST_NAVIGATION = '/__last-navigation'
32
33const SHELL_CACHE = `${CACHE_PREFIX}shell-${VERSION}`
34const PAGES_CACHE = `${CACHE_PREFIX}pages-${VERSION}`
35const STATIC_CACHE = `${CACHE_PREFIX}static-${VERSION}`
36const IMAGES_CACHE = `${CACHE_PREFIX}images-${VERSION}`
37const OWN_CACHES = [SHELL_CACHE, PAGES_CACHE, STATIC_CACHE, IMAGES_CACHE, STATE_CACHE]
38
39const OFFLINE_URL = '/offline'
40const CACHED_AT = 'x-oi-cached-at'
41
42// Сутки: новость старше суток человек примет за сегодняшнюю и не поймёт, что
43// читает вчерашний день — честная заглушка лучше.
44const PAGE_MAX_AGE = 24 * 60 * 60 * 1000
45
46// Сколько ждать сеть, прежде чем показать копию из кэша.
47//
48// Опасен не обрыв связи — при нём fetch падает сразу, и копия отдаётся мгновенно.
49// Опасна сеть, которая ЕСТЬ, но молчит: так бывает, пока просыпается радио
50// телефона после сна. Без предела worker ждал её сколько угодно, и человек
51// смотрел на пустой экран много секунд — жалоба владельца 09.09.2026:
52// «запускаю с иконки, белый экран, вообще ничего не грузит, перезахожу — норм».
53// Откуда три секунды. Это бюджет на ЗАГОЛОВКИ ответа, а не на всю страницу:
54// fetch разрешается, как только пришли заголовки. По смоук-замерам прода первый
55// байт главной приходит за 0,31–0,46 с, то есть три секунды — это семь-десять
56// обычных ответов подряд. Верхняя граница взята из наблюдавшейся патологии: 32
57// секунды на первый запрос к главной после пересоздания контейнера; такой случай
58// предел и отсекает. Уточнение сессии omskinform.
59const NETWORK_TIMEOUT = 3000
60
61// Потолки считаны по весу живых страниц: HTML материала около 210 КБ
62// несжатым, главная — 530 КБ, картинка — оÐ
62ºÐ¾Ð»Ð¾ 200 КБ. Тридцать страниц и
63// шестьдесят картинок дают порядка 18 МБ; на такой величине браузер хранилище
64// не выбрасывает. Файлы сборки ограничены отдельно: их имена меняются с каждым
65// выкатом, поэтому без потолка комплект чанков копился бы вечно.
66const LIMITS = {
67  [PAGES_CACHE]: 30,
68  [STATIC_CACHE]: 120,
69  [IMAGES_CACHE]: 60,
70}
71
72const isOwnRequest = (request) => {
73  if (request.method !== 'GET') {
74    return false
75  }
76
77  const url = new URL(request.url)
78
79  return url.origin === self.location.origin && !url.searchParams.has('_rsc') && !url.pathname.startsWith('/api/')
80}
81
82// Имена файлов сборки содержат хэш: после выката меняется само имя, и старый
83// файл новый подменить не может. Иконки и шрифты меняются раз в год.
84const isBuildAsset = (url) =>
85  url.pathname.startsWith('/_next/static/') ||
86  url.pathname.startsWith('/icon-') ||
87  url.pathname === '/apple-touch-icon.png' ||
88  url.pathname.endsWith('.woff2')
89
90const isArticleImage = (url) => url.pathname === '/_next/image'
91
92const dropOldest = async (cache, limit) => {
93  const keys = await cache.keys()
94
95  for (const key of keys.slice(0, keys.length - limit)) {
96    await cache.delete(key)
97  }
98}
99
100// Отметка времени нужна только страницам: у них есть срок годности, у файлов
101// сборки и картинок его нет — их адрес меняется вместе с содержимым.
102const stamped = async (response) => {
103  const headers = new Headers(response.headers)
104  headers.set(CACHED_AT, String(Date.now()))
105
106  return new Response(await response.blob(), {
107    status: response.status,
108    statusText: response.statusText,
109    headers,
110  })
111}
112
113// Ответ после перенаправления класть нельзя: у навигации redirect === 'manual',
114// и отданный из кэша ответ с redirected === true роняет respondWith — человек
115// увидит ошибку сети вместо заглушки.
116const keep = async (cacheName, request, response) => {
117  if (!response.ok || response.redirected) {
118    return
119  }
120
121  const cache = await caches.open(cacheName)
122  await cache.put(request, cacheName === PAGES_CACHE ? await stamped(response) : response)
123  await dropOldest(cache, LIMITS[cacheName])
124}
125
126const isFresh = (response) => {
127  const savedAt = Number(response.headers.get(CACHED_AT))
128
129  return Boolean(savedAt) && Date.now() - savedAt < PAGE_MAX_AGE
130}
131
132// Установка бывает и без сети, поэтому заглушку докладываем при первом удачном
133// переходе: ждать следующей установки нельзя, она может не случиться месяцами.
134const ensureOfflinePage = async () => {
135  const shell = await caches.open(SHELL_CACHE)
136
137  if (await shell.match(OFFLINE_URL)) {
138    return
139  }
140
141  try {
142    await shell.addAll([OFFLINE_URL])
143  } catch (precacheError) {
144    // Разбирать нечего: сети может не быть, это не поломка.
145  }
146}
147
148/**
149 * Отметить, что человеку показана сохранённая копия, — или снять отметку, когда
150 * пришла живая страница. Снимать обязательно: иначе полоска «показано
151 * сохранённое» осталась бы висеть после возвращения связи.
152 */
153const markNavigation = async (request, cachedAt) => {
154  const state = await caches.open(STATE_CACHE)
155  const key = new URL(LAST_NAVIGATION, self.location.origin).href
156
157  if (!cachedAt) {
158    await state.delete(key)
159
160    return
161  }
162
163  const telo = JSON.stringify({ url: request.url, cachedAt })
164
165  await state.put(key, new Response(telo, { headers: { 'content-type': 'application/json' } }))
166}
167
168const offlineFallback = async (request) => {
169  const pages = await caches.open(PAGES_CACHE)
170  const seenBefore = await pages.match(request)
171
172  if (seenBefore && isFresh(seenBefore)) {
173    // Тот же расчёт, что и для живой страницы: отказ хранилища не должен
174    // отнимать у человека копию, которая уже нашлась.
175    await markNavigation(request, Number(seenBefore.headers.get(CACHED_AT))).catch(() => undefined)
176
177    return seenBefore
178  }
179
180  const shell = await caches.open(SHELL_CACHE)
181
182  return (await shell.match(OFFLINE_URL)) || new Response('Нет связи', { status: 503 })
183}
184
185const freshCopy = async (request) => {
186  const pages = await caches.open(PAGES_CACHE)
187  const stored = await pages.match(request)
188
189  return stored && isFresh(stored) ? stored : undefined
190}
191
192// Возвращает и обещание, и способ снять таймер: без снятия он доживал бы до
193// срабатывания уже после того, как сеть ответила, — мелочь, но висящих таймеров
194// в worker'е быть не должно.
195const afterTimeout = (ms) => {
196  let timer
197
198  return {
199    promise: new Promise((resolve) => {
200      timer = setTimeout(() => resolve(undefined), ms)
201    }),
202    cancel: () => clearTimeout(timer),
203  }
204}
205
206// Ответ уходит человеку сразу, запись идёт следом в waitUntil. Дождись мы
207// записи — потоковая отрисовка App Router выключилась бы на каждом переходе у
208// каждого посетителя: клон читается до конца, а главная весит полмегабайта.
209const keepPage = (event, response) => {
210  event.waitUntil(keep(PAGES_CACHE, event.request, response.clone()))
211  event.waitUntil(ensureOfflinePage())
212}
213
214const pageFromNetwork = async (event) => {
215  const fromNetwork = fetch(event.request)
216  const copy = await freshCopy(event.request)
217
218  // Копии нет — ждём сеть сколько потребуется: показать «нет связи» при живом
219  // интернете хуже, чем подождать.
220  if (!copy) {
221    try {
222      const response = await fromNetwork
223
224      keepPage(event, response)
225
226      return response
227    } catch (networkError) {
228      return offlineFallback(event.request)
229    }
230  }
231
232  const predel = afterTimeout(NETWORK_TIMEOUT)
233  const response = await Promise.race([fromNetwork.catch(() => undefined), predel.promise])
234
235  predel.cancel()
236
237  if (response) {
238    keepPage(event, response)
239
240    // Снятие отметки ждём ДО ответа, а не в фоне: страница читает её сразу после
241    // загрузки и успела бы увидеть прошлую — полоска «показано сохранённое»
242    // висела бы на живой странице. Довод «не блокировать ответ» написан про
243    // keep(): тот читает тело целиком, а здесь одна короткая запись.
244    //
245    // Ошибку гасим намеренно, и это не украшательство: Cache Storage отказывает
246    // в приватном режиме и когда кончилось место. Без catch такой отказ вылетел
247    // бы в общий блок ниже, и человек получил бы КОПИЮ вместо живой страницы,
248    // которая уже у нас в руках, — да ещё с полоской поверх, ведь отметка не
249    // снялась. Две ошибки за один сбой хранилища. Условие сессии omskinform.
250    await markNavigation(event.request, 0).catch(() => undefined)
251
252    return response
253  }
254
255  // Сеть не ответила вовремя: человек получает вчерашнюю-сегодняшнюю копию
256  // сразу, а свежая догружается в фоне и ляжет в кэш к следующему запуску.
257  //
258  // TODO(adminoi, 2026-10-09): добавить третье состояние «связь есть, ответа
259  // нет» для тех, у кого копии нет вовсе. Покрытие этой правки обратно
260  // соотносится с бедой: ежедневный читатель спасён свежей копией, а редкий
261  // уходит в ветку !copy и видит тот же белый экран — при том, что у него радио
262  // спало дольше и молчащая сеть вероятнее. То есть без третьего состояния
263  // незакрытым остаётся самый частый источник жалобы. Замечание сессии
264  // omskinform.
265  //
266  // Второй источник той же беды нашла сессия oi на своей панели: сразу после
267  // перезапуска сервера соединение не молчит, а ОТВЕРГАЕТСЯ — и такой отказ
268  // легко принять за офлайн. У неё это длилось 1,6 секунды, после чего первый
269  // ответ пришёл через 36 секунд. Значит третье состояние нужно и для отказа
270  // соединения при живом интернете, а не только для молчания сети.
271  event.waitUntil(fromNetwork.then((late) => keep(PAGES_CACHE, event.request, late.clone())).catch(() => undefined))
272
273  // Отметка «показана копия» обязана стоять и здесь. Эта ветка появилась вместе
274  // с пределом ожидания и забирает у offlineFallback главный случай отдачи
275  // копии: сеть отказала или промолчала, а свежая копия есть. Без отметки
276  // полоска «Показано сохранённое» не покажется именно тогда, когда нужна.
277  await markNavigation(event.request, Number(copy.headers.get(CACHED_AT))).catch(() => undefined)
278
279  return copy
280}
281
282const fromCacheFirst = async (event, cacheName) => {
283  const cache = await caches.open(cacheName)
284  const cached = await cache.match(event.request)
285
286  if (cached) {
287    return cached
288  }
289
290  const response = await fetch(event.request)
291  event.waitUntil(keep(cacheName, event.request, response.clone()))
292
293  return response
294}
295
296const dropForeignCaches = async () => {
297  const names = await caches.keys()
298  const stale = names.filter((name) => name.startsWith(CACHE_PREFIX) && !OWN_CACHES.includes(name))
299
300  await Promise.all(stale.map((name) => caches.delete(name)))
301}
302
303self.addEventListener('install', (event) => {
304  event.waitUntil(ensureOfflinePage().then(() =>
304 self.skipWaiting()))
305})
306
307self.addEventListener('activate', (event) => {
308  event.waitUntil(dropForeignCaches().then(() => self.clients.claim()))
309})
310
311self.addEventListener('fetch', (event) => {
312  if (!isOwnRequest(event.request)) {
313    return
314  }
315
316  if (event.request.mode === 'navigate') {
317    event.respondWith(pageFromNetwork(event))
318
319    return
320  }
321
322  const url = new URL(event.request.url)
323
324  if (isBuildAsset(url)) {
325    event.respondWith(fromCacheFirst(event, STATIC_CACHE))
326
327    return
328  }
329
330  if (isArticleImage(url)) {
331    event.respondWith(fromCacheFirst(event, IMAGES_CACHE))
332  }
333})
334
335self.addEventListener('push', (event) => {
336  let data = {}
337  try {
338    data = event.data ? event.data.json() : {}
339  } catch (error) {
340    data = {}
341  }
342
343  const title = data.title || 'Омск-информ'
344  const options = {
345    body: data.body || '',
346    icon: '/icon-192.png',
347    badge: '/icon-192.png',
348    tag: data.tag,
349    data: { url: data.url || '/' },
350  }
351
352  event.waitUntil(self.registration.showNotification(title, options))
353})
354
355self.addEventListener('notificationclick', (event) => {
356  event.notification.close()
357  const url = (event.notification.data && event.notification.data.url) || '/'
358
359  event.waitUntil(
360    self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
361      for (const client of clientList) {
362        if ('focus' in client) {
363          client.navigate(url)
364          return client.focus()
365        }
366      }
367      return self.clients.openWindow(url)
368    }),
369  )
370})

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.