diff options
Diffstat (limited to 'tools/wasm_demo/service_worker.js')
-rw-r--r-- | tools/wasm_demo/service_worker.js | 317 |
1 files changed, 317 insertions, 0 deletions
diff --git a/tools/wasm_demo/service_worker.js b/tools/wasm_demo/service_worker.js new file mode 100644 index 0000000..531e5c2 --- /dev/null +++ b/tools/wasm_demo/service_worker.js @@ -0,0 +1,317 @@ +// Copyright (c) the JPEG XL Project Authors. All rights reserved. +// +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +/* + * ServiceWorker script. + * + * Multi-threading in WASM is currently implemented by the means of + * SharedArrayBuffer. Due to infamous vulnerabilities this feature is disabled + * unless site is running in "cross-origin isolated" mode. + * If there is not enough control over the server (e.g. when pages are hosted as + * "github pages") ServiceWorker is used to upgrade responses with corresponding + * headers. + * + * This script could be executed in 2 environments: HTML page or ServiceWorker. + * The environment is detected by the type of "window" reference. + * + * When this script is executed from HTML page then ServiceWorker is registered. + * Page reload might be necessary in some situations. By default it is done via + * `window.location.reload()`. However this can be altered by setting a + * configuration object `window.serviceWorkerConfig`. It's `doReload` property + * should be a replacement callable. + * + * When this script is executed from ServiceWorker then standard lifecycle + * event dispatchers are setup along with `fetch` interceptor. + */ + +(() => { + // Set COOP/COEP headers for document/script responses; use when this can not + // be done on server side (e.g. GitHub Pages). + const FORCE_COP = true; + // Interpret 'content-type: application/octet-stream' as JXL; use when server + // does not set appropriate content type (e.g. GitHub Pages). + const FORCE_DECODING = true; + // Embedded (baked-in) responses for faster turn-around. + const EMBEDDED = { + 'client_worker.js': '$client_worker.js$', + 'jxl_decoder.js': '$jxl_decoder.js$', + 'jxl_decoder.worker.js': '$jxl_decoder.worker.js$', + }; + + // Enable SharedArrayBuffer. + const setCopHeaders = (headers) => { + headers.set('Cross-Origin-Embedder-Policy', 'require-corp'); + headers.set('Cross-Origin-Opener-Policy', 'same-origin'); + }; + + // Inflight object: {clientId, uid, timestamp, controller} + const inflight = []; + + // Generate (very likely) unique string. + const makeUid = () => { + return Math.random().toString(36).substring(2) + + Math.random().toString(36).substring(2); + }; + + // Make list (non-recursively) of transferable entities. + const gatherTransferrables = (...args) => { + const result = []; + for (let i = 0; i < args.length; ++i) { + if (args[i] && args[i].buffer) { + result.push(args[i].buffer); + } + } + return result; + }; + + // Serve items that are embedded in this service worker. + const maybeProcessEmbeddedResources = (event) => { + const url = event.request.url; + // Shortcut for baked-in scripts. + for (const [key, value] of Object.entries(EMBEDDED)) { + if (url.endsWith(key)) { + const headers = new Headers(); + headers.set('Content-Type', 'application/javascript'); + setCopHeaders(headers); + + event.respondWith(new Response(value, { + status: 200, + statusText: 'OK', + headers: headers, + })); + return true; + } + } + return false; + }; + + // Decode JXL image response and serve it as a PNG image. + const wrapImageResponse = async (clientId, originalResponse) => { + // TODO(eustas): cache? + const client = await clients.get(clientId); + // Client is gone? Not our problem then. + if (!client) { + return originalResponse; + } + + const inputStream = await originalResponse.body; + // Can't use "BYOB" for regular responses. + const reader = inputStream.getReader(); + + const inflightEntry = { + clientId: clientId, + uid: makeUid(), + timestamp: Date.now(), + inputStreamReader: reader, + outputStreamController: null + }; + inflight.push(inflightEntry); + + const outputStream = new ReadableStream({ + start: (controller) => { + inflightEntry.outputStreamController = controller; + } + }); + + const onRead = (chunk) => { + const msg = { + op: 'decodeJxl', + uid: inflightEntry.uid, + url: originalResponse.url, + data: chunk.value || null + }; + client.postMessage(msg, gatherTransferrables(msg.data)); + if (!chunk.done) { + reader.read().then(onRead); + } + }; + // const view = new SharedArrayBuffer(65536); + const view = new Uint8Array(65536); + reader.read(view).then(onRead); + + let modifiedResponseHeaders = new Headers(originalResponse.headers); + modifiedResponseHeaders.delete('Content-Length'); + modifiedResponseHeaders.set('Content-Type', 'image/png'); + modifiedResponseHeaders.set('Server', 'ServiceWorker'); + return new Response(outputStream, {headers: modifiedResponseHeaders}); + }; + + // Check if response needs decoding; if so - do it. + const wrapImageRequest = async (clientId, request) => { + let modifiedRequestHeaders = new Headers(request.headers); + modifiedRequestHeaders.append('Accept', 'image/jxl'); + let modifiedRequest = + new Request(request, {headers: modifiedRequestHeaders}); + let originalResponse = await fetch(modifiedRequest); + let contentType = originalResponse.headers.get('Content-Type'); + + let isJxlResponse = (contentType === 'image/jxl'); + if (FORCE_DECODING && contentType === 'application/octet-stream') { + isJxlResponse = true; + } + if (isJxlResponse) { + return wrapImageResponse(clientId, originalResponse); + } + + return originalResponse; + }; + + const reportError = (err) => { + // console.error(err); + }; + + const upgradeResponse = (response) => { + if (response.status === 0) { + return response; + } + + const newHeaders = new Headers(response.headers); + setCopHeaders(newHeaders); + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: newHeaders, + }); + }; + + // Process fetch request; either bypass, or serve embedded resource, + // or upgrade. + const onFetch = async (event) => { + const clientId = event.clientId; + const request = event.request; + + // Pass direct cached resource requests. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + return; + } + + // Serve backed resources. + if (maybeProcessEmbeddedResources(event)) { + return; + } + + // Notify server we are JXL-capable. + if (request.destination === 'image') { + let accept = request.headers.get('Accept'); + // Only if browser does not support JXL. + if (accept.indexOf('image/jxl') === -1) { + event.respondWith(wrapImageRequest(clientId, request)); + } + return; + } + + if (FORCE_COP) { + event.respondWith( + fetch(event.request).then(upgradeResponse).catch(reportError)); + } + }; + + // Serve decoded bytes. + const onMessage = (event) => { + const data = event.data; + const uid = data.uid; + let inflightEntry = null; + for (let i = 0; i < inflight.length; ++i) { + if (inflight[i].uid === uid) { + inflightEntry = inflight[i]; + break; + } + } + if (!inflightEntry) { + console.log('Ooops, not found: ' + uid); + return; + } + inflightEntry.outputStreamController.enqueue(data.data); + inflightEntry.outputStreamController.close(); + }; + + // This method is "main" for service worker. + const serviceWorkerMain = () => { + // https://v8.dev/blog/wasm-code-caching + // > Every web site must perform at least one full compilation of a + // > WebAssembly module — use workers to hide that from your users. + // TODO(eustas): not 100% reliable, investigate why + self['JxlDecoderLeak'] = + WebAssembly.compileStreaming(fetch('jxl_decoder.wasm')); + + // ServiceWorker lifecycle. + self.addEventListener('install', () => { + return self.skipWaiting(); + }); + self.addEventListener( + 'activate', (event) => event.waitUntil(self.clients.claim())); + self.addEventListener('message', onMessage); + // Intercept some requests. + self.addEventListener('fetch', onFetch); + }; + + // Service workers does not support multi-threading; that is why decoding is + // relayed back to "client" (document / window). + const prepareClient = () => { + const clientWorker = new Worker('client_worker.js'); + clientWorker.onmessage = (event) => { + const data = event.data; + if (typeof addMessage !== 'undefined') { + if (data.msg) { + addMessage(data.msg, 'blue'); + } + } + navigator.serviceWorker.controller.postMessage( + data, gatherTransferrables(data.data)); + }; + + // Forward ServiceWorker requests to "Client" worker. + navigator.serviceWorker.addEventListener('message', (event) => { + clientWorker.postMessage( + event.data, gatherTransferrables(event.data.data)); + }); + }; + + // Executed in HTML page environment. + const maybeRegisterServiceWorker = () => { + const config = { + log: console.log, + error: console.error, + requestReload: (msg) => window.location.reload(), + ...window.serviceWorkerConfig // add overrides + } + + if (!window.isSecureContext) { + config.log('Secure context is required for this ServiceWorker.'); + return; + } + + const nav = navigator; // Explicitly capture navigator object. + const onServiceWorkerRegistrationSuccess = (registration) => { + config.log('Service Worker registered', registration.scope); + if (!registration.active || !nav.serviceWorker.controller) { + config.requestReload( + 'Reload to allow Service Worker process all requests'); + } + }; + + const onServiceWorkerRegistrationFailure = (err) => { + config.error('Service Worker failed to register:', err); + }; + + navigator.serviceWorker.register(window.document.currentScript.src) + .then( + onServiceWorkerRegistrationSuccess, + onServiceWorkerRegistrationFailure); + }; + + const pageMain = () => { + maybeRegisterServiceWorker(); + prepareClient(); + }; + + // Detect environment and run corresponding "main" method. + if (typeof window === 'undefined') { + serviceWorkerMain(); + } else { + pageMain(); + } +})(); |