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.