PageSourceSearch

https://lokali.at/sw.js

js lokali.at collected 2026-10-02 01:16:38 UTC 11,532 bytes, 257 lines download raw bytes

1/**
2 * Lokali Web-Push Service-Worker.
3 *
4 * Verantwortlichkeiten:
5 *   1. install/activate: claim clients, skip waiting damit der neue
6 *      SW sofort aktiv ist und nicht erst nach Browser-Restart.
7 *   2. push: empfaengt Push vom Browser-Push-Service, parsed JSON,
8 *      zeigt System-Notification.
9 *   3. notificationclick: User klickt Notification → focus existierenden
10 *      Tab oder oeffnet neuen mit Deep-Link aus payload.data.url.
11 *   4. pushsubscriptionchange: Subscription wurde vom Browser invalidiert
12 *      (z.B. nach Browser-Restart oder Push-Service-Rotation) → neu
13 *      subscriben + Backend benachrichtigen.
14 *
15 * KEINE Bundling-Pipeline: dieser SW ist plain-JS und wird direkt aus
16 * /public ausgeliefert. Browser cached ihn aggressiv → bei Aenderungen
17 * Service-Worker-Version unten bumpen damit Browser den neuen SW
18 * registriert.
19 */
20
21const SW_VERSION = '2026-07-10-001'
22
23/**
24 * API origin for backend calls from the Service-Worker (notifications-emails-09).
25 *
26 * The SW is served from the web origin (lokali.at), but the API lives on
27 * api.lokali.at. A relative '/api/...' fetch therefore hit lokali.at and 404'd,
28 * silently losing the web-push channel on endpoint rotation. There is no bundler
29 * env here (plain /public JS), so we derive the API host from the SW's own
30 * location: the `api.` subdomain of the registrable domain in production, and
31 * the dev API port on localhost (matches NEXT_PUBLIC_API_URL default 5001).
32 */
33function apiBase() {
34  const { hostname, protocol } = self.location
35  if (hostname === 'localhost' || hostname === '127.0.0.1') {
36    return `${protocol}//${hostname}:5001`
37  }
38  const bare = hostname.replace(/^www\./, '')
39  return `https://api.${bare}`
40}
41
42self.addEventListener('install', (event) => {
43  console.log(`[lokali-sw] install ${SW_VERSION}`)
44  // skipWaiting: neuer SW wird sofort aktiv statt erst nach Tab-Schliessen.
45  // Critical fuer Bug-Fixes - sonst koennen User wochenlang den alten
46  // SW behalten.
47  self.skipWaiting()
48})
49
50self.addEventListener('activate', (event) => {
51  console.log(`[lokali-sw] activate ${SW_VERSION}`)
52  // clients.claim: aktiver SW kontrolliert sofort alle offenen Tabs
53  // ohne dass die Tabs neu geladen werden muessen.
54  event.waitUntil(self.clients.claim())
55})
56
57self.addEventListener('push', (event) => {
58  if (!event.data) return
59  let payload
60  try {
61    payload = event.data.json()
62  } catch {
63    // Fallback wenn payload Plain-Text ist (sollte nicht passieren da
64    // unser Backend immer JSON sendet, aber defensiv).
65    payload = { title: 'Lokali', body: event.data.text() }
66  }
67
68  const title = payload.title || 'Lokali'
69  const options = {
70    body: payload.body || '',
71    // Standard-Icon. Browser zeigt das wenn kein Vendor-Logo verfuegbar.
72    // Pfad relativ zum Origin - icons sind in /public.
73    icon: payload.data?.icon || '/icon-192.png',
74    badge: payload.data?.badge || '/badge-72.png',
75    // Tag erlaubt Notifications mit gleichem Tag zu ersetzen (z.B. bei
76    // mehreren Nachrichten in der selben Conv nur die letzte zeigen).
77    tag: payload.data?.tag || payload.data?.conversationId || undefined,
78    // renotify=true: wenn Tag matched, neue Notification weckt den User
79    // trotzdem (statt silent-replace). Sinnvoll bei neuen Nachrichten.
80    renotify: payload.data?.renotify ?? true,
81    // Data wird beim notificationclick verfuegbar gemacht - hier
82    // packen wir Deep-Link + Type rein.
83    data: payload.data || {},
84    // requireInteraction false → Notification verschwindet nach paar
85    // Sekunden. Bei wichtigen Sachen (z.B. Bewerbung-Frist) kann der
86    // Backend payload.data.requireInteraction=true senden.
87    requireInteraction: payload.data?.requireInteraction ?? false,
88  }
89
90  event.waitUntil(self.registration.showNotification(title, options))
91})
92
93self.addEventListener('notificationclick', (event) => {
94  event.notification.close()
95
96  const data = event.notification.data || {}
97  const targetUrl = resolveDeepLink(data)
98
99  event.waitUntil(
100    self.clients
101      .matchAll({ type: 'window', includeUncontrolled: true })
102      .then((windowClients) => {
103        // Wenn schon ein Lokali-Tab offen ist: focus + navigate
104        for (const client of windowClients) {
105          // URL-Match heuristic: gleicher Origin reicht. Wir wollen NICHT
106          // mehrere Lokali-Tabs aufmachen.
107          if (client.url.includes(self.location.origin)) {
108            return client.focus().then(() => {
109              if ('navigate' in client) {
110                return client.navigate(targetUrl)
111              }
112              // Fallback: postMessage damit der Tab seinen Router umlenkt
113              return client.postMessage({ type: 'navigate', url: targetUrl })
114            })
115          }
116        }
117        // Kein offener Tab: neuen oeffnen.
118        return self.clients.openWindow(targetUrl)
119      }),
120  )
121})
122
123self.addEventListener('pushsubscriptionchange', (event) => {
124  // Browser hat unsere Subscription invalidiert (e.g. nach Push-Service-
125  // Migration). Wir muessen neu subscriben + den neuen Endpoint
126  // backend-seitig speichern. Ohne diesen Handler verlieren wir den
127  // Push-Channel still.
128  event.waitUntil(
129    (async () => {
130      try {
131        const newSub = await self.registration.pushManager.subscribe({
132          userVisibleOnly: true,
133          applicationServerKey: event.oldSubscription?.options?.applicationServerKey,
134        })
135        // Backend-Update via fetch. Cookie-basierte Auth weil Service-
136        // Worker kein Zugriff auf Bearer-Tokens hat - der Browser sendet
137        // die existierenden cookies automatisch. Absolute API-Origin, weil
138        // das Backend auf api.lokali.at liegt (notifications-emails-09).
139        await fetch(`${apiBase()}/api/users/me/web-push`, {
140          method: 'POST',
141          headers: { 'Content-Type': 'application/json' },
142          credentials: 'include',
143          body: JSON.stringify({
144            endpoint: newSub.endpoint,
145            keys: {
146              p256dh: arrayBufferToBase64(newSub.getKey('p256dh')),
147              auth: arrayBufferToBase64(newSub.getKey('auth')),
148            },
149            userAgent: self.navigator?.userAgent || null,
150          }),
151        })
152      } catch (err) {
153        console.error('[lokali-sw] pushsubscriptionchange resub failed:', err)
154      }
155    })(),
156  )
157})
158
159// ── Helpers ─────────────────────────────────────────────────────────
160
161function resolveDeepLink(data) {
162  // Explizite URL hat Vorrang
163  if (data.url) return data.url
164  // Type-based Routing (mirror von notification-deep-link.ts in Mobile)
165  switch (data.type) {
166    case 'new_message':
167      // scope=business wenn die Conv business-scoped ist, sonst personal.
168      // accountType ist server-side im notification-data, falls vorhanden.
169      return data.conversationId
170        ? `/mein-profil/nachrichten?c=${data.conversationId}${data.accountType === 'business' ? '&scope=business' : ''}`
171        : '/mein-profil/nachrichten'
172    case 'new_application':
173    case 'service_inquiry':
174      return '/mein-profil/bewerbungen' // owner sieht in bewerber-page, applicant in bewerbungen
175    case 'application_accepted':
176    case 'application_rejected':
177      return '/mein-profil/bewerbungen'
178    case 'appointment_proposed':
179    case 'appointment_confirmed':
180    case 'appointment_cancelled':
181      return data.appointmentId
182        ? `/mein-profil/termine?id=${data.appointmentId}`
183        : '/mein-profil/termine'
184    case 'new_rating':
185      return '/mein-profil/bewertungen'
186    case 'matching_task':
187    case 'nearby_task':
188    case 'new_task_from_following':
189      // Welten-Migration: Klickziel liegt jetzt in der Dienste-Welt (WELT-05).
190      //
191      // HAENGT AN DER REDIRECT-MATRIX in apps/web/next.config.ts, und zwar in
192      // beide Richtungen. Gecachte Kopien des ALTEN Service Workers liegen noch
193      // monatelang auf Nutzergeraeten und schicken Leute weiter auf den alten
194      // Inserate-Detailpfad, bis jedes Geraet diese Datei neu geholt hat. Genau
195      // deshalb ist die Matrix permanent und nicht uebergangsweise. Wer spaeter
196      // meint, der Sweep habe sie ueberfluessig gemacht: nein.
197      //
198      // WEITERHIN BEWUSST OHNE PRAEFIX, aber der Grund hat sich geaendert.
199      //
200      // Bis zur Land-Migration stand im Pfad die SPRACHE, und das Argument war:
201      // der Proxy erkennt sie und haengt sie an, ein hartes /de/ wuerde einen
202      // polnischen Nutzer auf die deutsche Seite zwingen. Sprachsegmente gibt es
203      // nicht mehr, im Pfad steht jetzt das LAND, und das Argument ist damit
204      // staerker geworden statt schwaecher: ein hartes /at/ wuerde einen
205      // deutschen Nutzer dauerhaft auf die oesterreichische Plattform zwingen,
206      // und zwar mit Status 200, also ohne dass es irgendwo auffiele.
207      //
208      // Diese Datei ist genau der Ort, an dem das am teuersten waere. Sie liegt
209      // gecacht auf Nutzergeraeten, siehe SW_VERSION oben: ein falsches Land
210      // haelt sich hier Monate, laenger als jede andere Kopie im System.
211      //
212      // Aufgeloest wird es serverseitig, mit dem Cookie des Nutzers:
213      // apps/web/src/proxy.ts, Zweig 4 legt eine praefixlose landgebundene
214      // Adresse per 302 auf die Plattform des Besuchers. Das ist Entscheidung 1
215      // der Migration (.planning/ENTSCHEIDUNGEN-migration-10-punkte.md), und sie
216      // nennt Push-Ziele ausdruecklich als ihren Grund: Bestaende auf fremden
217      // Geraeten lassen sich nicht nachdeployen.
218      //
219      // WELCHE ZIELE LANDGEBUNDEN SIND, und was das je Fall kostet: seit dem
220      // 2026-08-23 ALLE. Hier stand, /mein-profil/... und /benachrichtigungen
221      // seien landlos und wuerden praefixlos direkt ausgeliefert; der Nachtrag
222      // des Gruenders hat die landlosen Routen aufgehoben, sie liegen jetzt
223      // unter app/[locale]/[country]/. Jedes Ziel dieser Datei kostet damit
224      // einen 302 ueber Zweig 4.
225      //
226      // DAS BLEIBT TROTZDEM RICHTIG SO, und der Grund ist derselbe wie oben, nur
227      // staerker: diese Datei liegt gecacht auf fremden Geraeten und kann ihr
228      // Land nicht kennen. Ein Sprung, den der Server mit dem Cookie des
229      // Besuchers aufloest, ist besser als ein hartes /at/, das ein
230      // kroatischer Nutzer monatelang mit sich herumtraegt. Der Preis der
231      // Aenderung ist ein zusaetzlicher Hop je Notification-Klick, nicht ein
232      // falsches Ziel.
233      //
234      // Der Service Worker kann die Welt eines Posts nicht aufloesen, er hat nur
235      // eine taskId und keinen postType. Ein Push zu einer Ausschreibung erzeugt
236      // deshalb einen Dienste-Pfad und wird vom D-02-Fangnetz auf der
237      // Dienste-Detailroute in einem weiteren Hop korrigiert. Akzeptiert: ein Hop
238      // fuer einen Notification-Klick, nicht fuer eine indexierte URL, und die
239      // Alternative waere ein Laufzeit-Lookup in einem Service Worker.
240      return data.taskId ? `/dienste/inserat/${data.taskId}` : '/dienste'
241    default:
242      return '/benachrichtigungen'
243  }
244}
245
246function arrayBufferToBase64(buffer) {
247  if (!buffer) return ''
248  const bytes = new Uint8Array(buffer)
249  let binary = ''
250  for (const b of bytes) binary += String.fromCharCode(b)
251  // btoa returns base64 with +/= padding. Web-Push API erwartet URL-
252  // safe base64 (- statt +, _ statt /). Wir convertieren.
253  return self
254    .btoa(binary)
255    .replace(/\+/g, '-')
256    .replace(/\//g, '_')
257}

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.