service_worker.js (10211B)
1 // Copyright (c) the JPEG XL Project Authors. All rights reserved. 2 // 3 // Use of this source code is governed by a BSD-style 4 // license that can be found in the LICENSE file. 5 6 /* 7 * ServiceWorker script. 8 * 9 * Multi-threading in WASM is currently implemented by the means of 10 * SharedArrayBuffer. Due to infamous vulnerabilities this feature is disabled 11 * unless site is running in "cross-origin isolated" mode. 12 * If there is not enough control over the server (e.g. when pages are hosted as 13 * "github pages") ServiceWorker is used to upgrade responses with corresponding 14 * headers. 15 * 16 * This script could be executed in 2 environments: HTML page or ServiceWorker. 17 * The environment is detected by the type of "window" reference. 18 * 19 * When this script is executed from HTML page then ServiceWorker is registered. 20 * Page reload might be necessary in some situations. By default it is done via 21 * `window.location.reload()`. However this can be altered by setting a 22 * configuration object `window.serviceWorkerConfig`. It's `doReload` property 23 * should be a replacement callable. 24 * 25 * When this script is executed from ServiceWorker then standard lifecycle 26 * event dispatchers are setup along with `fetch` interceptor. 27 */ 28 29 (() => { 30 // Set COOP/COEP headers for document/script responses; use when this can not 31 // be done on server side (e.g. GitHub Pages). 32 const FORCE_COP = true; 33 // Interpret 'content-type: application/octet-stream' as JXL; use when server 34 // does not set appropriate content type (e.g. GitHub Pages). 35 const FORCE_DECODING = true; 36 // Embedded (baked-in) responses for faster turn-around. 37 const EMBEDDED = { 38 'client_worker.js': '$client_worker.js$', 39 'jxl_decoder.js': '$jxl_decoder.js$', 40 'jxl_decoder.worker.js': '$jxl_decoder.worker.js$', 41 }; 42 43 // Enable SharedArrayBuffer. 44 const setCopHeaders = (headers) => { 45 headers.set('Cross-Origin-Embedder-Policy', 'require-corp'); 46 headers.set('Cross-Origin-Opener-Policy', 'same-origin'); 47 }; 48 49 // Inflight object: {clientId, uid, timestamp, controller} 50 const inflight = []; 51 52 // Generate (very likely) unique string. 53 const makeUid = () => { 54 return Math.random().toString(36).substring(2) + 55 Math.random().toString(36).substring(2); 56 }; 57 58 // Make list (non-recursively) of transferable entities. 59 const gatherTransferrables = (...args) => { 60 const result = []; 61 for (let i = 0; i < args.length; ++i) { 62 if (args[i] && args[i].buffer) { 63 result.push(args[i].buffer); 64 } 65 } 66 return result; 67 }; 68 69 // Serve items that are embedded in this service worker. 70 const maybeProcessEmbeddedResources = (event) => { 71 const url = event.request.url; 72 // Shortcut for baked-in scripts. 73 for (const [key, value] of Object.entries(EMBEDDED)) { 74 if (url.endsWith(key)) { 75 const headers = new Headers(); 76 headers.set('Content-Type', 'application/javascript'); 77 setCopHeaders(headers); 78 79 event.respondWith(new Response(value, { 80 status: 200, 81 statusText: 'OK', 82 headers: headers, 83 })); 84 return true; 85 } 86 } 87 return false; 88 }; 89 90 // Decode JXL image response and serve it as a PNG image. 91 const wrapImageResponse = async (clientId, originalResponse) => { 92 // TODO(eustas): cache? 93 const client = await clients.get(clientId); 94 // Client is gone? Not our problem then. 95 if (!client) { 96 return originalResponse; 97 } 98 99 const inputStream = await originalResponse.body; 100 // Can't use "BYOB" for regular responses. 101 const reader = inputStream.getReader(); 102 103 const inflightEntry = { 104 clientId: clientId, 105 uid: makeUid(), 106 timestamp: Date.now(), 107 inputStreamReader: reader, 108 outputStreamController: null 109 }; 110 inflight.push(inflightEntry); 111 112 const outputStream = new ReadableStream({ 113 start: (controller) => { 114 inflightEntry.outputStreamController = controller; 115 } 116 }); 117 118 const onRead = (chunk) => { 119 const msg = { 120 op: 'decodeJxl', 121 uid: inflightEntry.uid, 122 url: originalResponse.url, 123 data: chunk.value || null 124 }; 125 client.postMessage(msg, gatherTransferrables(msg.data)); 126 if (!chunk.done) { 127 reader.read().then(onRead); 128 } 129 }; 130 // const view = new SharedArrayBuffer(65536); 131 const view = new Uint8Array(65536); 132 reader.read(view).then(onRead); 133 134 let modifiedResponseHeaders = new Headers(originalResponse.headers); 135 modifiedResponseHeaders.delete('Content-Length'); 136 modifiedResponseHeaders.set('Content-Type', 'image/png'); 137 modifiedResponseHeaders.set('Server', 'ServiceWorker'); 138 return new Response(outputStream, {headers: modifiedResponseHeaders}); 139 }; 140 141 // Check if response needs decoding; if so - do it. 142 const wrapImageRequest = async (clientId, request) => { 143 let modifiedRequestHeaders = new Headers(request.headers); 144 modifiedRequestHeaders.append('Accept', 'image/jxl'); 145 let modifiedRequest = 146 new Request(request, {headers: modifiedRequestHeaders}); 147 let originalResponse = await fetch(modifiedRequest); 148 let contentType = originalResponse.headers.get('Content-Type'); 149 150 let isJxlResponse = (contentType === 'image/jxl'); 151 if (FORCE_DECODING && contentType === 'application/octet-stream') { 152 isJxlResponse = true; 153 } 154 if (isJxlResponse) { 155 return wrapImageResponse(clientId, originalResponse); 156 } 157 158 return originalResponse; 159 }; 160 161 const reportError = (err) => { 162 // console.error(err); 163 }; 164 165 const upgradeResponse = (response) => { 166 if (response.status === 0) { 167 return response; 168 } 169 170 const newHeaders = new Headers(response.headers); 171 setCopHeaders(newHeaders); 172 173 return new Response(response.body, { 174 status: response.status, 175 statusText: response.statusText, 176 headers: newHeaders, 177 }); 178 }; 179 180 // Process fetch request; either bypass, or serve embedded resource, 181 // or upgrade. 182 const onFetch = async (event) => { 183 const clientId = event.clientId; 184 const request = event.request; 185 186 // Pass direct cached resource requests. 187 if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { 188 return; 189 } 190 191 // Serve backed resources. 192 if (maybeProcessEmbeddedResources(event)) { 193 return; 194 } 195 196 // Notify server we are JXL-capable. 197 if (request.destination === 'image') { 198 let accept = request.headers.get('Accept'); 199 // Only if browser does not support JXL. 200 if (accept.indexOf('image/jxl') === -1) { 201 event.respondWith(wrapImageRequest(clientId, request)); 202 } 203 return; 204 } 205 206 if (FORCE_COP) { 207 event.respondWith( 208 fetch(event.request).then(upgradeResponse).catch(reportError)); 209 } 210 }; 211 212 // Serve decoded bytes. 213 const onMessage = (event) => { 214 const data = event.data; 215 const uid = data.uid; 216 let inflightEntry = null; 217 for (let i = 0; i < inflight.length; ++i) { 218 if (inflight[i].uid === uid) { 219 inflightEntry = inflight[i]; 220 break; 221 } 222 } 223 if (!inflightEntry) { 224 console.log('Ooops, not found: ' + uid); 225 return; 226 } 227 inflightEntry.outputStreamController.enqueue(data.data); 228 inflightEntry.outputStreamController.close(); 229 }; 230 231 // This method is "main" for service worker. 232 const serviceWorkerMain = () => { 233 // https://v8.dev/blog/wasm-code-caching 234 // > Every web site must perform at least one full compilation of a 235 // > WebAssembly module — use workers to hide that from your users. 236 // TODO(eustas): not 100% reliable, investigate why 237 self['JxlDecoderLeak'] = 238 WebAssembly.compileStreaming(fetch('jxl_decoder.wasm')); 239 240 // ServiceWorker lifecycle. 241 self.addEventListener('install', () => { 242 return self.skipWaiting(); 243 }); 244 self.addEventListener( 245 'activate', (event) => event.waitUntil(self.clients.claim())); 246 self.addEventListener('message', onMessage); 247 // Intercept some requests. 248 self.addEventListener('fetch', onFetch); 249 }; 250 251 // Service workers does not support multi-threading; that is why decoding is 252 // relayed back to "client" (document / window). 253 const prepareClient = () => { 254 const clientWorker = new Worker('client_worker.js'); 255 clientWorker.onmessage = (event) => { 256 const data = event.data; 257 if (typeof addMessage !== 'undefined') { 258 if (data.msg) { 259 addMessage(data.msg, 'blue'); 260 } 261 } 262 navigator.serviceWorker.controller.postMessage( 263 data, gatherTransferrables(data.data)); 264 }; 265 266 // Forward ServiceWorker requests to "Client" worker. 267 navigator.serviceWorker.addEventListener('message', (event) => { 268 clientWorker.postMessage( 269 event.data, gatherTransferrables(event.data.data)); 270 }); 271 }; 272 273 // Executed in HTML page environment. 274 const maybeRegisterServiceWorker = () => { 275 const config = { 276 log: console.log, 277 error: console.error, 278 requestReload: (msg) => window.location.reload(), 279 ...window.serviceWorkerConfig // add overrides 280 } 281 282 if (!window.isSecureContext) { 283 config.log('Secure context is required for this ServiceWorker.'); 284 return; 285 } 286 287 const nav = navigator; // Explicitly capture navigator object. 288 const onServiceWorkerRegistrationSuccess = (registration) => { 289 config.log('Service Worker registered', registration.scope); 290 if (!registration.active || !nav.serviceWorker.controller) { 291 config.requestReload( 292 'Reload to allow Service Worker process all requests'); 293 } 294 }; 295 296 const onServiceWorkerRegistrationFailure = (err) => { 297 config.error('Service Worker failed to register:', err); 298 }; 299 300 navigator.serviceWorker.register(window.document.currentScript.src) 301 .then( 302 onServiceWorkerRegistrationSuccess, 303 onServiceWorkerRegistrationFailure); 304 }; 305 306 const pageMain = () => { 307 maybeRegisterServiceWorker(); 308 prepareClient(); 309 }; 310 311 // Detect environment and run corresponding "main" method. 312 if (typeof window === 'undefined') { 313 serviceWorkerMain(); 314 } else { 315 pageMain(); 316 } 317 })();