A music player that connects to your cloud/distributed storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: offline orchestrator with service worker that makes everything previously requested available offline (uses content-addressing)

+235 -50
+10 -5
src/_includes/layouts/diffuse.vto
··· 2 2 title: "Diffuse" 3 3 4 4 base: "./" 5 - scripts: [] 6 - styles: [] 7 5 --- 8 6 9 7 <html lang="en"> ··· 12 10 13 11 <meta name="color-scheme" /> 14 12 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 13 + 14 + <meta name="media-controllable" /> 15 + <meta name="mobile-web-app-capable" content="yes" /> 15 16 16 17 <title>{{title}}</title> 17 18 ··· 31 32 {{ for url of styles }} 32 33 <link rel="stylesheet" href="{{ url }}" /> 33 34 {{ /for }} 35 + </head> 36 + <body> 37 + {{ content }} 38 + 39 + <!-- Make every touched URL available offline --> 40 + <do-offline></do-offline> 41 + <script src="components/orchestrator/offline/element.js" type="module"></script> 34 42 35 43 <!-- Scripts --> 36 44 {{ for url of scripts }} 37 45 <script src="{{ url }}" type="module"></script> 38 46 {{ /for }} 39 - </head> 40 - <body> 41 - {{ content }} 42 47 </body> 43 48 </html>
-44
src/_includes/layouts/facet.vto
··· 1 - --- 2 - title: "Diffuse" 3 - 4 - base: "./" 5 - --- 6 - 7 - <html lang="en"> 8 - <head> 9 - <meta charset="UTF-8" /> 10 - 11 - <meta name="color-scheme" /> 12 - <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 13 - 14 - <meta name="media-controllable" /> 15 - <meta name="mobile-web-app-capable" content="yes" /> 16 - 17 - <title>{{title}}</title> 18 - 19 - <!-- Base --> 20 - <base href="{{base}}" /> 21 - 22 - <!-- Favicons & Mobile --> 23 - <link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png" /> 24 - <link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png" /> 25 - <link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png" /> 26 - <!-- TODO: <link rel="manifest" href="site.webmanifest" />--> 27 - <link rel="mask-icon" href="safari-pinned-tab.svg" color="#8a90a9" /> 28 - <meta name="msapplication-TileColor" content="#8a90a9" /> 29 - <meta name="theme-color" content="#8a90a9" /> 30 - 31 - <!-- Styles --> 32 - {{ for url of styles }} 33 - <link rel="stylesheet" href="{{ url }}" /> 34 - {{ /for }} 35 - </head> 36 - <body> 37 - {{ content }} 38 - 39 - <!-- Scripts --> 40 - {{ for url of scripts }} 41 - <script src="{{ url }}" type="module"></script> 42 - {{ /for }} 43 - </body> 44 - </html>
+63
src/components/orchestrator/offline/element.js
··· 1 + import { DiffuseElement } from "~/common/element.js"; 2 + 3 + //////////////////////////////////////////// 4 + // ELEMENT 5 + //////////////////////////////////////////// 6 + 7 + /** 8 + * Registers a service worker that makes the page available offline. 9 + * 10 + * All resources fetched by the page are cached as they load. 11 + * While online, requests always go to the network (the cache is bypassed), 12 + * and successful responses are stored for later. While offline, the cache 13 + * is used as a fallback. 14 + * 15 + * Attributes: 16 + * cache-name Name of the cache to use (default: "diffuse-offline") 17 + * scope Service worker scope (default: document.baseURI ?? "./") 18 + * src URL of the service worker script (default: built-in service-worker.js) 19 + * Must be served from a path within the requested scope, or the server 20 + * must include a `Service-Worker-Allowed: /` response header. 21 + */ 22 + class OfflineOrchestrator extends DiffuseElement { 23 + static NAME = "diffuse/orchestrator/offline"; 24 + 25 + // LIFECYCLE 26 + 27 + /** @override */ 28 + async connectedCallback() { 29 + super.connectedCallback(); 30 + 31 + if (!("serviceWorker" in navigator)) return; 32 + 33 + const cacheName = this.getAttribute("cache-name") ?? "diffuse-offline"; 34 + const scope = this.getAttribute("scope") ?? document.baseURI ?? "./"; 35 + const src = this.getAttribute("src"); 36 + 37 + const swUrl = new URL( 38 + src ?? import.meta.resolve("../../../service-worker-offline.js"), 39 + ); 40 + 41 + swUrl.searchParams.set("cache-name", cacheName); 42 + 43 + try { 44 + await navigator.serviceWorker.register(swUrl.href, { 45 + type: "module", 46 + scope, 47 + }); 48 + } catch (error) { 49 + console.warn("[do-offline] Failed to register service worker:", error); 50 + } 51 + } 52 + } 53 + 54 + export default OfflineOrchestrator; 55 + 56 + //////////////////////////////////////////// 57 + // REGISTER 58 + //////////////////////////////////////////// 59 + 60 + export const CLASS = OfflineOrchestrator; 61 + export const NAME = "do-offline"; 62 + 63 + customElements.define(NAME, OfflineOrchestrator);
+1 -1
src/facets/l/index.vto
··· 1 1 --- 2 - layout: layouts/facet.vto 2 + layout: layouts/diffuse.vto 3 3 base: ../../ 4 4 5 5 styles:
+161
src/service-worker-offline.js
··· 1 + /// <reference no-default-lib="true" /> 2 + /// <reference lib="webworker" /> 3 + 4 + import { create as createCid } from "./common/cid.js"; 5 + 6 + /** Media content types to ignore */ 7 + const MEDIA_CONTENT_TYPE = /^(audio|video)\//; 8 + 9 + /** Multicodec code for raw binary content. */ 10 + const RAW_CODEC = 0x55; 11 + 12 + const { searchParams } = new URL(location.href); 13 + const CACHE_NAME = searchParams.get("cache-name") ?? "diffuse-offline"; 14 + 15 + const thyself = 16 + /** @type {ServiceWorkerGlobalScope & typeof globalThis} */ (/** @type {unknown} */ (self)); 17 + 18 + //////////////////////////////////////////// 19 + // INSTALL 20 + //////////////////////////////////////////// 21 + 22 + self.addEventListener("install", () => { 23 + // Activate immediately without waiting for existing clients to close. 24 + thyself.skipWaiting(); 25 + }); 26 + 27 + //////////////////////////////////////////// 28 + // ACTIVATE 29 + //////////////////////////////////////////// 30 + 31 + self.addEventListener("activate", (event) => { 32 + // Take control of all open clients right away. 33 + /** @type {ExtendableEvent} */ (event).waitUntil(thyself.clients.claim()); 34 + }); 35 + 36 + //////////////////////////////////////////// 37 + // FETCH 38 + //////////////////////////////////////////// 39 + 40 + self.addEventListener("fetch", (_event) => { 41 + const event = /** @type {FetchEvent} */ (_event); 42 + const { request } = event; 43 + 44 + // Only intercept GET requests over http(s). 45 + if (request.method !== "GET") return; 46 + if (!request.url.startsWith("http")) return; 47 + 48 + event.respondWith(handleFetch(request)); 49 + }); 50 + 51 + //////////////////////////////////////////// 52 + // CACHE (content-addressed) 53 + //////////////////////////////////////////// 54 + 55 + /** 56 + * @param {string} cid 57 + */ 58 + function cidUrl(cid) { 59 + return `https://diffuse.offline.worker/${cid}`; 60 + } 61 + 62 + /** 63 + * Opens the two caches used for content-addressed storage. 64 + * 65 + * - `<name>:index` maps original request URL → CID string (text/plain) 66 + * - `<name>:content` maps `https://diffuse.offline.worker/<cid>` → full response (deduplicated) 67 + */ 68 + async function openCaches() { 69 + const [index, content] = await Promise.all([ 70 + caches.open(CACHE_NAME + ":index"), 71 + caches.open(CACHE_NAME + ":content"), 72 + ]); 73 + return { index, content }; 74 + } 75 + 76 + /** 77 + * Computes the CID of `response`'s body and writes it into the two-level cache. 78 + * The same content is stored only once, regardless of how many URLs reference it. 79 + * 80 + * @param {Request} request 81 + * @param {Response} response - a clone; its body is fully consumed here 82 + */ 83 + async function store(request, response) { 84 + const bytes = new Uint8Array(await response.clone().arrayBuffer()); 85 + const cid = await createCid(RAW_CODEC, bytes); 86 + const cidKey = cidUrl(cid); 87 + 88 + const caches = await openCaches(); 89 + 90 + // Only store the content if we haven't seen this CID before 91 + if (!(await caches.content.match(cidKey))) { 92 + await caches.content.put(new Request(cidKey), response); 93 + } 94 + 95 + // Update the URL → CID map 96 + await caches.index.put( 97 + new Request(request.url), 98 + new Response(cid, { headers: { "content-type": "text/plain" } }), 99 + ); 100 + } 101 + 102 + /** 103 + * Resolves the cached response for a request via the URL → CID index. 104 + * 105 + * @param {Request} request 106 + * @returns {Promise<Response | undefined>} 107 + */ 108 + async function lookup(request) { 109 + const caches = await openCaches(); 110 + 111 + const indexEntry = await caches.index.match(request); 112 + if (!indexEntry) return undefined; 113 + 114 + const cid = await indexEntry.text(); 115 + return caches.content.match(cidUrl(cid)); 116 + } 117 + 118 + //////////////////////////////////////////// 119 + // HANDLER 120 + //////////////////////////////////////////// 121 + 122 + /** 123 + * Network-first strategy with content-addressed caching. 124 + * 125 + * Online → fetch from network, store response by CID, return it. 126 + * Offline → resolve the URL through the index, serve by CID from the content cache. 127 + * 128 + * Partial responses (206) are passed through without caching so that 129 + * range requests for audio streaming work as normal. 130 + * 131 + * @param {Request} request 132 + * @returns {Promise<Response>} 133 + */ 134 + async function handleFetch(request) { 135 + // When we know we're offline, skip the network entirely. 136 + if (navigator.onLine) { 137 + const response = await fetch(request); 138 + 139 + // Partial content (range requests) — return as-is, do not cache. 140 + if (response.status === 206) return response; 141 + 142 + // Skip caching audio/video. 143 + const contentType = response.headers.get("content-type") ?? ""; 144 + if (MEDIA_CONTENT_TYPE.test(contentType)) return response; 145 + 146 + // Cache full successful responses, including opaque cross-origin ones. 147 + if (response.status === 200 || response.type === "opaque") { 148 + store(request, response.clone()); 149 + } 150 + 151 + return response; 152 + } 153 + 154 + const cached = await lookup(request); 155 + if (cached) return cached; 156 + 157 + return new Response(null, { 158 + status: 503, 159 + statusText: "Unavailable asset, not cached", 160 + }); 161 + }