summaryrefslogtreecommitdiff
path: root/tools/wasm_demo/service_worker.js
diff options
context:
space:
mode:
Diffstat (limited to 'tools/wasm_demo/service_worker.js')
-rw-r--r--tools/wasm_demo/service_worker.js317
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();
+ }
+})();