libjxl

FORK: libjxl patches used on blog
git clone https://git.neptards.moe/blog/libjxl.git
Log | Files | Refs | Submodules | README | LICENSE

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 })();